From 1eb73d9fa63f7a1a42bb288d9c87f9912f0621df Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 10:04:25 +0200 Subject: [PATCH 001/159] Rename test case to better reflect the intent of the tests --- ...edLoaderTests.swift => LoadFeedFromRemoteUseCaseTests.swift} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename EssentialFeed/EssentialFeedTests/Feed Api/{RemoteFeedLoaderTests.swift => LoadFeedFromRemoteUseCaseTests.swift} (99%) diff --git a/EssentialFeed/EssentialFeedTests/Feed Api/RemoteFeedLoaderTests.swift b/EssentialFeed/EssentialFeedTests/Feed Api/LoadFeedFromRemoteUseCaseTests.swift similarity index 99% rename from EssentialFeed/EssentialFeedTests/Feed Api/RemoteFeedLoaderTests.swift rename to EssentialFeed/EssentialFeedTests/Feed Api/LoadFeedFromRemoteUseCaseTests.swift index 7a7fea1..a09fd5a 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Api/RemoteFeedLoaderTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Api/LoadFeedFromRemoteUseCaseTests.swift @@ -8,7 +8,7 @@ import XCTest import EssentialFeed -class RemoteFeedLoaderTests: XCTestCase { +class LoadFeedFromRemoteUseCaseTests: XCTestCase { func test_init_doesNotRequestDataFromURL() { let (_, client) = makeSUT() From 4bfac7acf50554d54f6b269503ff101288d61b6a Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 10:16:47 +0200 Subject: [PATCH 002/159] Does not delete cache upon LocalFeedLoader creation --- .../EssentialFeed.xcodeproj/project.pbxproj | 12 +++++++++ .../Feed Cache/CacheFeedUseCaseTests.swift | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 0def712..dd3d805 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 080EDF0C21B6DAE800813479 /* FeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0B21B6DAE800813479 /* FeedItem.swift */; }; 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0D21B6DCB600813479 /* FeedLoader.swift */; }; 40B002492CF9E9DB0058D3E0 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; + 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -38,6 +39,7 @@ 080EDF0B21B6DAE800813479 /* FeedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItem.swift; sourceTree = ""; }; 080EDF0D21B6DCB600813479 /* FeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoader.swift; sourceTree = ""; }; 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -126,6 +128,7 @@ 080EDEFE21B6DA7E00813479 /* EssentialFeedTests */ = { isa = PBXGroup; children = ( + 40B975392D9E7AD3009652B5 /* Feed Cache */, 40124C322CF8BDD5008BBDB6 /* Feed Api */, 080EDF0121B6DA7E00813479 /* Info.plist */, ); @@ -141,6 +144,14 @@ path = "Feed Feature"; sourceTree = ""; }; + 40B975392D9E7AD3009652B5 /* Feed Cache */ = { + isa = PBXGroup; + children = ( + 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */, + ); + path = "Feed Cache"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -298,6 +309,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift new file mode 100644 index 0000000..08471a9 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -0,0 +1,26 @@ +// +// CacheFeedUseCaseTests.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 3/4/25. +// + +import XCTest + +class LocalFeedLoader { + init(store: FeedStore) { + + } +} +class FeedStore { + var deleteCachedFeedCallCount = 0 +} + +class CacheFeedUseCaseTests: XCTestCase { + + func test_doesNotDeleteCacheUponCreation() { + let store = FeedStore() + let _ = LocalFeedLoader(store: store) + XCTAssertEqual(store.deleteCachedFeedCallCount, 0) + } +} From 11afe3bcafb73848c9e827f9edd24b00c1f6b2b8 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 10:20:08 +0200 Subject: [PATCH 003/159] Save command requests cache deletioin --- .../Feed Cache/CacheFeedUseCaseTests.swift | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 08471a9..8a8acaf 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -5,15 +5,25 @@ // Created by Cristian Felipe Patiño Rojas on 3/4/25. // +import EssentialFeed import XCTest class LocalFeedLoader { + private let store: FeedStore init(store: FeedStore) { - + self.store = store + } + + func save(_ items: [FeedItem]) { + store.deleteCachedFeed() } } class FeedStore { var deleteCachedFeedCallCount = 0 + + func deleteCachedFeed() { + deleteCachedFeedCallCount += 1 + } } class CacheFeedUseCaseTests: XCTestCase { @@ -23,4 +33,20 @@ class CacheFeedUseCaseTests: XCTestCase { let _ = LocalFeedLoader(store: store) XCTAssertEqual(store.deleteCachedFeedCallCount, 0) } + + func test_save_requestsCacheDeletion() { + let store = FeedStore() + let sut = LocalFeedLoader(store: store) + let items = [uniqueItem()] + sut.save(items) + XCTAssertEqual(store.deleteCachedFeedCallCount, 1) + } + + func uniqueItem() -> FeedItem { + return FeedItem(id: UUID(), description: "any", location: "any", imageURL: anyURL()) + } + + private func anyURL() -> URL { + URL(string: "http://any-url.com")! + } } From 6c585fc38b94f3ba81226d9855adf7a859afec48 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 10:21:59 +0200 Subject: [PATCH 004/159] Extract system under test creation to a factory method to protect tests against breaking changes --- .../Feed Cache/CacheFeedUseCaseTests.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 8a8acaf..a7f07f2 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -29,19 +29,25 @@ class FeedStore { class CacheFeedUseCaseTests: XCTestCase { func test_doesNotDeleteCacheUponCreation() { - let store = FeedStore() - let _ = LocalFeedLoader(store: store) + let (_, store) = makeSUT() XCTAssertEqual(store.deleteCachedFeedCallCount, 0) } func test_save_requestsCacheDeletion() { - let store = FeedStore() - let sut = LocalFeedLoader(store: store) + let (sut, store) = makeSUT() let items = [uniqueItem()] sut.save(items) XCTAssertEqual(store.deleteCachedFeedCallCount, 1) } + // MARK: - Helpers + + private func makeSUT() -> (sut: LocalFeedLoader, store: FeedStore) { + let store = FeedStore() + let sut = LocalFeedLoader(store: store) + return (sut, store) + } + func uniqueItem() -> FeedItem { return FeedItem(id: UUID(), description: "any", location: "any", imageURL: anyURL()) } From a98769c1f76a5eeab50622e54c5b00813b2334fc Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 10:22:58 +0200 Subject: [PATCH 005/159] Move memory leak tracking extension from the Feed API module scope to a module-level shared scope --- EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj | 9 ++++++--- .../Helpers/XCTestCase+MemoryLeakTrackingHelper.swift | 0 2 files changed, 6 insertions(+), 3 deletions(-) rename EssentialFeed/EssentialFeedTests/{Feed Api => }/Helpers/XCTestCase+MemoryLeakTrackingHelper.swift (100%) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index dd3d805..254b83d 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -43,10 +43,10 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 40B002502CF9F0420058D3E0 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + 40B975402D9E7CB2009652B5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - "Helpers/XCTestCase+MemoryLeakTrackingHelper.swift", + "XCTestCase+MemoryLeakTrackingHelper.swift", ); target = 40B002442CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */; }; @@ -63,8 +63,9 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 40124C322CF8BDD5008BBDB6 /* Feed Api */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (40B002502CF9F0420058D3E0 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "Feed Api"; sourceTree = ""; }; + 40124C322CF8BDD5008BBDB6 /* Feed Api */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Feed Api"; sourceTree = ""; }; 40B002462CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EssentialFeedAPIEndToEndTests; sourceTree = ""; }; + 40B9753D2D9E7CB2009652B5 /* Helpers */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (40B975402D9E7CB2009652B5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Helpers; sourceTree = ""; }; 40C57D7A2CF7C16E00518522 /* FeedApi */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (40C57D7D2CF7C19100518522 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = FeedApi; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -128,6 +129,7 @@ 080EDEFE21B6DA7E00813479 /* EssentialFeedTests */ = { isa = PBXGroup; children = ( + 40B9753D2D9E7CB2009652B5 /* Helpers */, 40B975392D9E7AD3009652B5 /* Feed Cache */, 40124C322CF8BDD5008BBDB6 /* Feed Api */, 080EDF0121B6DA7E00813479 /* Info.plist */, @@ -198,6 +200,7 @@ ); fileSystemSynchronizedGroups = ( 40124C322CF8BDD5008BBDB6 /* Feed Api */, + 40B9753D2D9E7CB2009652B5 /* Helpers */, ); name = EssentialFeedTests; productName = EssentialFeedTests; diff --git a/EssentialFeed/EssentialFeedTests/Feed Api/Helpers/XCTestCase+MemoryLeakTrackingHelper.swift b/EssentialFeed/EssentialFeedTests/Helpers/XCTestCase+MemoryLeakTrackingHelper.swift similarity index 100% rename from EssentialFeed/EssentialFeedTests/Feed Api/Helpers/XCTestCase+MemoryLeakTrackingHelper.swift rename to EssentialFeed/EssentialFeedTests/Helpers/XCTestCase+MemoryLeakTrackingHelper.swift From 841817ff66437b316aa12b7385a7d9bf13999cf8 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 10:23:55 +0200 Subject: [PATCH 006/159] Add memory leak tracking --- .../EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index a7f07f2..2822444 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -42,9 +42,11 @@ class CacheFeedUseCaseTests: XCTestCase { // MARK: - Helpers - private func makeSUT() -> (sut: LocalFeedLoader, store: FeedStore) { + private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: LocalFeedLoader, store: FeedStore) { let store = FeedStore() let sut = LocalFeedLoader(store: store) + trackForMemoryLeaks(store, file: file, line: line) + trackForMemoryLeaks(sut, file: file, line: line) return (sut, store) } From 1848e88f6cb28c1cb0dc235cf10f44c5f5df36bb Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 10:26:33 +0200 Subject: [PATCH 007/159] Save command does not request cache insertion on deletion error --- .../Feed Cache/CacheFeedUseCaseTests.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 2822444..4e57875 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -18,12 +18,18 @@ class LocalFeedLoader { store.deleteCachedFeed() } } + class FeedStore { var deleteCachedFeedCallCount = 0 + var insertCallCount = 0 func deleteCachedFeed() { deleteCachedFeedCallCount += 1 } + + func completeDeletion(with error: Error, at index: Int = 0) { + + } } class CacheFeedUseCaseTests: XCTestCase { @@ -40,6 +46,15 @@ class CacheFeedUseCaseTests: XCTestCase { XCTAssertEqual(store.deleteCachedFeedCallCount, 1) } + func test_save_doesNotRequestCacheInsertionOnDeletionError() { + let (sut, store) = makeSUT() + let items = [uniqueItem()] + let deletionError = anyNSError() + sut.save(items) + store.completeDeletion(with: deletionError) + XCTAssertEqual(store.insertCallCount, 0) + } + // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: LocalFeedLoader, store: FeedStore) { @@ -57,4 +72,6 @@ class CacheFeedUseCaseTests: XCTestCase { private func anyURL() -> URL { URL(string: "http://any-url.com")! } + + private func anyNSError() -> NSError { NSError(domain: "any error", code: 0) } } From a31734318c9ce0da6437fda68b7197b6eee7f1f3 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 10:31:58 +0200 Subject: [PATCH 008/159] Save command requests new cache insertion on succesful deletion --- .../Feed Cache/CacheFeedUseCaseTests.swift | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 4e57875..9942783 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -15,7 +15,11 @@ class LocalFeedLoader { } func save(_ items: [FeedItem]) { - store.deleteCachedFeed() + store.deleteCachedFeed { [unowned self] error in + if error == nil { + self.store.insert(items) + } + } } } @@ -23,12 +27,24 @@ class FeedStore { var deleteCachedFeedCallCount = 0 var insertCallCount = 0 - func deleteCachedFeed() { + typealias DeletionCompletion = (Error?) -> Void + private var deletionCompletions = [DeletionCompletion]() + + func deleteCachedFeed(completion: @escaping DeletionCompletion) { deleteCachedFeedCallCount += 1 + deletionCompletions.append(completion) } func completeDeletion(with error: Error, at index: Int = 0) { - + deletionCompletions[index](error) + } + + func completeDeletionSuccesfully(at index: Int = 0) { + deletionCompletions[index](nil) + } + + func insert(_ items: [FeedItem]) { + insertCallCount += 1 } } @@ -55,6 +71,14 @@ class CacheFeedUseCaseTests: XCTestCase { XCTAssertEqual(store.insertCallCount, 0) } + func test_save_requestsNewCacheInsertionOnSuccesfulDeletion() { + let (sut, store) = makeSUT() + let items = [uniqueItem()] + sut.save(items) + store.completeDeletionSuccesfully() + XCTAssertEqual(store.insertCallCount, 1) + } + // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: LocalFeedLoader, store: FeedStore) { From b9b79db3ec7350eb333241319ece52e597e4c7d4 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 10:38:42 +0200 Subject: [PATCH 009/159] Save command requests cache insertion with timestamp on succesful deletion --- .../Feed Cache/CacheFeedUseCaseTests.swift | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 9942783..acb35e6 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -10,24 +10,27 @@ import XCTest class LocalFeedLoader { private let store: FeedStore - init(store: FeedStore) { + private let currentDate: () -> Date + init(store: FeedStore, currentDate: @escaping () -> Date) { self.store = store + self.currentDate = currentDate } func save(_ items: [FeedItem]) { store.deleteCachedFeed { [unowned self] error in if error == nil { - self.store.insert(items) + self.store.insert(items, timestamp: self.currentDate()) } } } } class FeedStore { + typealias DeletionCompletion = (Error?) -> Void + var deleteCachedFeedCallCount = 0 var insertCallCount = 0 - - typealias DeletionCompletion = (Error?) -> Void + var insertions = [(items: [FeedItem], timestamp: Date)]() private var deletionCompletions = [DeletionCompletion]() func deleteCachedFeed(completion: @escaping DeletionCompletion) { @@ -43,8 +46,9 @@ class FeedStore { deletionCompletions[index](nil) } - func insert(_ items: [FeedItem]) { + func insert(_ items: [FeedItem], timestamp: Date) { insertCallCount += 1 + insertions.append((items, timestamp)) } } @@ -79,11 +83,27 @@ class CacheFeedUseCaseTests: XCTestCase { XCTAssertEqual(store.insertCallCount, 1) } + func test_save_requestsNewCacheInsertionWithTimestampOnSuccesfulDeletion() { + let timestamp = Date() + let (sut, store) = makeSUT(currentDate: { timestamp }) + let items = [uniqueItem()] + sut.save(items) + store.completeDeletionSuccesfully() + + XCTAssertEqual(store.insertions.count, 1) + XCTAssertEqual(store.insertions.first?.items, items) + XCTAssertEqual(store.insertions.first?.timestamp, timestamp) + } + // MARK: - Helpers - private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: LocalFeedLoader, store: FeedStore) { + private func makeSUT( + currentDate: @escaping () -> Date = Date.init, + file: StaticString = #file, + line: UInt = #line + ) -> (sut: LocalFeedLoader, store: FeedStore) { let store = FeedStore() - let sut = LocalFeedLoader(store: store) + let sut = LocalFeedLoader(store: store, currentDate: currentDate) trackForMemoryLeaks(store, file: file, line: line) trackForMemoryLeaks(sut, file: file, line: line) return (sut, store) From dc8b1aa63abee8a07408114305f61d39422fb482 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 10:39:26 +0200 Subject: [PATCH 010/159] Remove redundant test code --- .../Feed Cache/CacheFeedUseCaseTests.swift | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index acb35e6..20d0825 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -29,7 +29,6 @@ class FeedStore { typealias DeletionCompletion = (Error?) -> Void var deleteCachedFeedCallCount = 0 - var insertCallCount = 0 var insertions = [(items: [FeedItem], timestamp: Date)]() private var deletionCompletions = [DeletionCompletion]() @@ -47,7 +46,6 @@ class FeedStore { } func insert(_ items: [FeedItem], timestamp: Date) { - insertCallCount += 1 insertions.append((items, timestamp)) } } @@ -72,15 +70,7 @@ class CacheFeedUseCaseTests: XCTestCase { let deletionError = anyNSError() sut.save(items) store.completeDeletion(with: deletionError) - XCTAssertEqual(store.insertCallCount, 0) - } - - func test_save_requestsNewCacheInsertionOnSuccesfulDeletion() { - let (sut, store) = makeSUT() - let items = [uniqueItem()] - sut.save(items) - store.completeDeletionSuccesfully() - XCTAssertEqual(store.insertCallCount, 1) + XCTAssertEqual(store.insertions.count, 0) } func test_save_requestsNewCacheInsertionWithTimestampOnSuccesfulDeletion() { From e3ab1aeced0129b34b9d3564b4a79b55e91a3021 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 10:45:21 +0200 Subject: [PATCH 011/159] Unify store helper received messages to guarantee order and simplify tests assertions --- .../Feed Cache/CacheFeedUseCaseTests.swift | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 20d0825..18e0a15 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -28,13 +28,17 @@ class LocalFeedLoader { class FeedStore { typealias DeletionCompletion = (Error?) -> Void - var deleteCachedFeedCallCount = 0 - var insertions = [(items: [FeedItem], timestamp: Date)]() private var deletionCompletions = [DeletionCompletion]() + private(set) var receivedMessages = [ReceivedMessage]() + + enum ReceivedMessage: Equatable { + case deleteCachedFeed + case insert([FeedItem], Date) + } func deleteCachedFeed(completion: @escaping DeletionCompletion) { - deleteCachedFeedCallCount += 1 deletionCompletions.append(completion) + receivedMessages.append(.deleteCachedFeed) } func completeDeletion(with error: Error, at index: Int = 0) { @@ -46,22 +50,22 @@ class FeedStore { } func insert(_ items: [FeedItem], timestamp: Date) { - insertions.append((items, timestamp)) + receivedMessages.append(.insert(items, timestamp)) } } class CacheFeedUseCaseTests: XCTestCase { - func test_doesNotDeleteCacheUponCreation() { + func test_doesNotMessageStoreUponCreation() { let (_, store) = makeSUT() - XCTAssertEqual(store.deleteCachedFeedCallCount, 0) + XCTAssertEqual(store.receivedMessages, []) } func test_save_requestsCacheDeletion() { let (sut, store) = makeSUT() let items = [uniqueItem()] sut.save(items) - XCTAssertEqual(store.deleteCachedFeedCallCount, 1) + XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) } func test_save_doesNotRequestCacheInsertionOnDeletionError() { @@ -70,7 +74,7 @@ class CacheFeedUseCaseTests: XCTestCase { let deletionError = anyNSError() sut.save(items) store.completeDeletion(with: deletionError) - XCTAssertEqual(store.insertions.count, 0) + XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) } func test_save_requestsNewCacheInsertionWithTimestampOnSuccesfulDeletion() { @@ -80,9 +84,7 @@ class CacheFeedUseCaseTests: XCTestCase { sut.save(items) store.completeDeletionSuccesfully() - XCTAssertEqual(store.insertions.count, 1) - XCTAssertEqual(store.insertions.first?.items, items) - XCTAssertEqual(store.insertions.first?.timestamp, timestamp) + XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed, .insert(items, timestamp)]) } // MARK: - Helpers From d985e6bd75da3079cd3962b1d8850841f961803c Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 11:29:44 +0200 Subject: [PATCH 012/159] Save commands fails on cache deletion error --- .../Feed Cache/CacheFeedUseCaseTests.swift | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 18e0a15..0f4500d 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -16,8 +16,9 @@ class LocalFeedLoader { self.currentDate = currentDate } - func save(_ items: [FeedItem]) { + func save(_ items: [FeedItem], completion: @escaping (Error?) -> Void) { store.deleteCachedFeed { [unowned self] error in + completion(error) if error == nil { self.store.insert(items, timestamp: self.currentDate()) } @@ -64,7 +65,7 @@ class CacheFeedUseCaseTests: XCTestCase { func test_save_requestsCacheDeletion() { let (sut, store) = makeSUT() let items = [uniqueItem()] - sut.save(items) + sut.save(items) { _ in } XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) } @@ -72,7 +73,7 @@ class CacheFeedUseCaseTests: XCTestCase { let (sut, store) = makeSUT() let items = [uniqueItem()] let deletionError = anyNSError() - sut.save(items) + sut.save(items) { _ in } store.completeDeletion(with: deletionError) XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) } @@ -81,12 +82,27 @@ class CacheFeedUseCaseTests: XCTestCase { let timestamp = Date() let (sut, store) = makeSUT(currentDate: { timestamp }) let items = [uniqueItem()] - sut.save(items) + sut.save(items) { _ in } store.completeDeletionSuccesfully() XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed, .insert(items, timestamp)]) } + func test_save_failsOnDeletionError() { + let items = [uniqueItem()] + let (sut, store) = makeSUT() + let deletionError = anyNSError() + let exp = expectation(description: "Wait for save completion") + var receivedError: Error? + sut.save(items) { error in + receivedError = error + exp.fulfill() + } + store.completeDeletion(with: deletionError) + wait(for: [exp], timeout: 1.0) + XCTAssertEqual(receivedError as NSError?, deletionError) + } + // MARK: - Helpers private func makeSUT( From bc909e6063a499d98ec9d4407cbb3012540b0ff6 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 11:38:52 +0200 Subject: [PATCH 013/159] Save command fails on cache insertion error --- .../Feed Cache/CacheFeedUseCaseTests.swift | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 0f4500d..95b73e0 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -18,9 +18,10 @@ class LocalFeedLoader { func save(_ items: [FeedItem], completion: @escaping (Error?) -> Void) { store.deleteCachedFeed { [unowned self] error in - completion(error) if error == nil { - self.store.insert(items, timestamp: self.currentDate()) + self.store.insert(items, timestamp: self.currentDate(), completion: completion) + } else { + completion(error) } } } @@ -28,8 +29,10 @@ class LocalFeedLoader { class FeedStore { typealias DeletionCompletion = (Error?) -> Void + typealias InsertionCompletion = (Error?) -> Void private var deletionCompletions = [DeletionCompletion]() + private var insertionCompletions = [InsertionCompletion]() private(set) var receivedMessages = [ReceivedMessage]() enum ReceivedMessage: Equatable { @@ -50,9 +53,14 @@ class FeedStore { deletionCompletions[index](nil) } - func insert(_ items: [FeedItem], timestamp: Date) { + func insert(_ items: [FeedItem], timestamp: Date, completion: @escaping InsertionCompletion) { + insertionCompletions.append(completion) receivedMessages.append(.insert(items, timestamp)) } + + func completeInsertion(with error: Error, at index: Int = 0) { + insertionCompletions[index](error) + } } class CacheFeedUseCaseTests: XCTestCase { @@ -103,6 +111,22 @@ class CacheFeedUseCaseTests: XCTestCase { XCTAssertEqual(receivedError as NSError?, deletionError) } + func test_save_failsOnInsertionError() { + let items = [uniqueItem()] + let (sut, store) = makeSUT() + let insertionError = anyNSError() + let exp = expectation(description: "Wait for save completion") + var receivedError: Error? + sut.save(items) { error in + receivedError = error + exp.fulfill() + } + store.completeDeletionSuccesfully() + store.completeInsertion(with: insertionError) + wait(for: [exp], timeout: 1.0) + XCTAssertEqual(receivedError as NSError?, insertionError) + } + // MARK: - Helpers private func makeSUT( From 0c8d98795fafdb179c816a72c4cdb7c4a46600ff Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 11:41:41 +0200 Subject: [PATCH 014/159] Save command succeds on succesful cache insertion --- .../Feed Cache/CacheFeedUseCaseTests.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 95b73e0..66b1837 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -61,6 +61,10 @@ class FeedStore { func completeInsertion(with error: Error, at index: Int = 0) { insertionCompletions[index](error) } + + func completeInsertionSuccesfully(at index: Int = 0) { + insertionCompletions[index](nil) + } } class CacheFeedUseCaseTests: XCTestCase { @@ -127,6 +131,21 @@ class CacheFeedUseCaseTests: XCTestCase { XCTAssertEqual(receivedError as NSError?, insertionError) } + func test_save_succeedsOnSuccesfulCacheInsertion() { + let items = [uniqueItem()] + let (sut, store) = makeSUT() + let exp = expectation(description: "Wait for save completion") + var receivedError: Error? + sut.save(items) { error in + receivedError = error + exp.fulfill() + } + store.completeDeletionSuccesfully() + store.completeInsertionSuccesfully() + wait(for: [exp], timeout: 1.0) + XCTAssertNil(receivedError as NSError?) + } + // MARK: - Helpers private func makeSUT( From 0a6a7834266f9d0b4175e7e5f9af29cfc6634718 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 11:48:24 +0200 Subject: [PATCH 015/159] Extract duplicated test code into a shared helper method --- .../Feed Cache/CacheFeedUseCaseTests.swift | 69 ++++++++++--------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 66b1837..8fcc524 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -101,49 +101,29 @@ class CacheFeedUseCaseTests: XCTestCase { } func test_save_failsOnDeletionError() { - let items = [uniqueItem()] let (sut, store) = makeSUT() let deletionError = anyNSError() - let exp = expectation(description: "Wait for save completion") - var receivedError: Error? - sut.save(items) { error in - receivedError = error - exp.fulfill() - } - store.completeDeletion(with: deletionError) - wait(for: [exp], timeout: 1.0) - XCTAssertEqual(receivedError as NSError?, deletionError) + expect(sut, toCompleteWithError: deletionError, when: { + store.completeDeletion(with: deletionError) + }) } func test_save_failsOnInsertionError() { - let items = [uniqueItem()] + let (sut, store) = makeSUT() let insertionError = anyNSError() - let exp = expectation(description: "Wait for save completion") - var receivedError: Error? - sut.save(items) { error in - receivedError = error - exp.fulfill() - } - store.completeDeletionSuccesfully() - store.completeInsertion(with: insertionError) - wait(for: [exp], timeout: 1.0) - XCTAssertEqual(receivedError as NSError?, insertionError) + expect(sut, toCompleteWithError: insertionError, when: { + store.completeDeletionSuccesfully() + store.completeInsertion(with: insertionError) + }) } func test_save_succeedsOnSuccesfulCacheInsertion() { - let items = [uniqueItem()] let (sut, store) = makeSUT() - let exp = expectation(description: "Wait for save completion") - var receivedError: Error? - sut.save(items) { error in - receivedError = error - exp.fulfill() - } - store.completeDeletionSuccesfully() - store.completeInsertionSuccesfully() - wait(for: [exp], timeout: 1.0) - XCTAssertNil(receivedError as NSError?) + expect(sut, toCompleteWithError: nil, when: { + store.completeDeletionSuccesfully() + store.completeInsertionSuccesfully() + }) } // MARK: - Helpers @@ -160,6 +140,31 @@ class CacheFeedUseCaseTests: XCTestCase { return (sut, store) } + private func expect( + _ sut: LocalFeedLoader, + toCompleteWithError expectedError: NSError?, + when action: () -> Void, + file: StaticString = #file, + line: UInt = #line + ) { + let exp = expectation(description: "Wait for save completion") + var receivedError: Error? + sut.save([uniqueItem()]) { error in + receivedError = error + exp.fulfill() + } + + action() + + wait(for: [exp], timeout: 1.0) + XCTAssertEqual( + receivedError as NSError?, + expectedError, + file: file, + line: line + ) + } + func uniqueItem() -> FeedItem { return FeedItem(id: UUID(), description: "any", location: "any", imageURL: anyURL()) } From 7a638318a4b24be29b4ba4af3292d0d6a4f290e8 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 11:52:09 +0200 Subject: [PATCH 016/159] Extract FeedStore protocol into a FeedStoreSpy helper --- .../Feed Cache/CacheFeedUseCaseTests.swift | 79 ++++++++++--------- 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 8fcc524..e5e6eb6 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -27,44 +27,12 @@ class LocalFeedLoader { } } -class FeedStore { +protocol FeedStore { typealias DeletionCompletion = (Error?) -> Void typealias InsertionCompletion = (Error?) -> Void - private var deletionCompletions = [DeletionCompletion]() - private var insertionCompletions = [InsertionCompletion]() - private(set) var receivedMessages = [ReceivedMessage]() - - enum ReceivedMessage: Equatable { - case deleteCachedFeed - case insert([FeedItem], Date) - } - - func deleteCachedFeed(completion: @escaping DeletionCompletion) { - deletionCompletions.append(completion) - receivedMessages.append(.deleteCachedFeed) - } - - func completeDeletion(with error: Error, at index: Int = 0) { - deletionCompletions[index](error) - } - - func completeDeletionSuccesfully(at index: Int = 0) { - deletionCompletions[index](nil) - } - - func insert(_ items: [FeedItem], timestamp: Date, completion: @escaping InsertionCompletion) { - insertionCompletions.append(completion) - receivedMessages.append(.insert(items, timestamp)) - } - - func completeInsertion(with error: Error, at index: Int = 0) { - insertionCompletions[index](error) - } - - func completeInsertionSuccesfully(at index: Int = 0) { - insertionCompletions[index](nil) - } + func deleteCachedFeed(completion: @escaping DeletionCompletion) + func insert(_ items: [FeedItem], timestamp: Date, completion: @escaping InsertionCompletion) } class CacheFeedUseCaseTests: XCTestCase { @@ -132,8 +100,8 @@ class CacheFeedUseCaseTests: XCTestCase { currentDate: @escaping () -> Date = Date.init, file: StaticString = #file, line: UInt = #line - ) -> (sut: LocalFeedLoader, store: FeedStore) { - let store = FeedStore() + ) -> (sut: LocalFeedLoader, store: FeedStoreSpy) { + let store = FeedStoreSpy() let sut = LocalFeedLoader(store: store, currentDate: currentDate) trackForMemoryLeaks(store, file: file, line: line) trackForMemoryLeaks(sut, file: file, line: line) @@ -174,4 +142,41 @@ class CacheFeedUseCaseTests: XCTestCase { } private func anyNSError() -> NSError { NSError(domain: "any error", code: 0) } + + private class FeedStoreSpy: FeedStore { + private var deletionCompletions = [DeletionCompletion]() + private var insertionCompletions = [InsertionCompletion]() + private(set) var receivedMessages = [ReceivedMessage]() + + enum ReceivedMessage: Equatable { + case deleteCachedFeed + case insert([FeedItem], Date) + } + + func deleteCachedFeed(completion: @escaping DeletionCompletion) { + deletionCompletions.append(completion) + receivedMessages.append(.deleteCachedFeed) + } + + func completeDeletion(with error: Error, at index: Int = 0) { + deletionCompletions[index](error) + } + + func completeDeletionSuccesfully(at index: Int = 0) { + deletionCompletions[index](nil) + } + + func insert(_ items: [FeedItem], timestamp: Date, completion: @escaping InsertionCompletion) { + insertionCompletions.append(completion) + receivedMessages.append(.insert(items, timestamp)) + } + + func completeInsertion(with error: Error, at index: Int = 0) { + insertionCompletions[index](error) + } + + func completeInsertionSuccesfully(at index: Int = 0) { + insertionCompletions[index](nil) + } + } } From 527ac0332807500c6841840fbd6212cd7f5f55a0 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 12:45:55 +0200 Subject: [PATCH 017/159] Guarantee that the `LocalFeedLoader`does not delvier deletion error after instance has been deallocated --- .../Feed Cache/CacheFeedUseCaseTests.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index e5e6eb6..93708a0 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -17,7 +17,8 @@ class LocalFeedLoader { } func save(_ items: [FeedItem], completion: @escaping (Error?) -> Void) { - store.deleteCachedFeed { [unowned self] error in + store.deleteCachedFeed { [weak self] error in + guard let self else { return } if error == nil { self.store.insert(items, timestamp: self.currentDate(), completion: completion) } else { @@ -94,6 +95,16 @@ class CacheFeedUseCaseTests: XCTestCase { }) } + func test_save_doesNotDeliverDeletionErrorAfterSUTInstanceHasBeenDeallocated() { + let store = FeedStoreSpy() + var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) + var receivedResults = [Error?]() + sut?.save([uniqueItem()]) { receivedResults.append($0) } + sut = nil + store.completeDeletion(with: anyNSError()) + XCTAssertTrue(receivedResults.isEmpty) + } + // MARK: - Helpers private func makeSUT( From dd954dee6f7ddf5716d8373c060f75ce0166d20b Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 12:48:20 +0200 Subject: [PATCH 018/159] Guarantee that the `LocalFeedLoader`does not deliver insertion error after instance has been deallocated --- .../Feed Cache/CacheFeedUseCaseTests.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 93708a0..e0ff378 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -20,7 +20,11 @@ class LocalFeedLoader { store.deleteCachedFeed { [weak self] error in guard let self else { return } if error == nil { - self.store.insert(items, timestamp: self.currentDate(), completion: completion) + self.store.insert(items, timestamp: self.currentDate()) { [weak self] error in + guard self != nil else { return } + completion(error) + + } } else { completion(error) } @@ -105,6 +109,17 @@ class CacheFeedUseCaseTests: XCTestCase { XCTAssertTrue(receivedResults.isEmpty) } + func test_save_doesNotDeliverInsertionErrorAfterSUTInstanceHasBeenDeallocated() { + let store = FeedStoreSpy() + var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) + var receivedResults = [Error?]() + sut?.save([uniqueItem()]) { receivedResults.append($0) } + store.completeDeletionSuccesfully() + sut = nil + store.completeInsertion(with: anyNSError()) + XCTAssertTrue(receivedResults.isEmpty) + } + // MARK: - Helpers private func makeSUT( From bec426a9fab8442defba6cbe9e08d3c24315a3f9 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 12:49:49 +0200 Subject: [PATCH 019/159] Invert `if` logic to make code paths easier to follow --- .../Feed Cache/CacheFeedUseCaseTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index e0ff378..11c8291 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -19,14 +19,14 @@ class LocalFeedLoader { func save(_ items: [FeedItem], completion: @escaping (Error?) -> Void) { store.deleteCachedFeed { [weak self] error in guard let self else { return } - if error == nil { + if let cacheDeletionError = error { + completion(cacheDeletionError) + } else { self.store.insert(items, timestamp: self.currentDate()) { [weak self] error in guard self != nil else { return } completion(error) } - } else { - completion(error) } } } @@ -82,7 +82,7 @@ class CacheFeedUseCaseTests: XCTestCase { } func test_save_failsOnInsertionError() { - + let (sut, store) = makeSUT() let insertionError = anyNSError() expect(sut, toCompleteWithError: insertionError, when: { From 7c197d303cc98e5568cb165f1c00de45424a71c7 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 12:51:13 +0200 Subject: [PATCH 020/159] Extract cache insertion into a helper function to make logic inside closure callbacks easier to follow --- .../Feed Cache/CacheFeedUseCaseTests.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 11c8291..d5aeddf 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -22,14 +22,17 @@ class LocalFeedLoader { if let cacheDeletionError = error { completion(cacheDeletionError) } else { - self.store.insert(items, timestamp: self.currentDate()) { [weak self] error in - guard self != nil else { return } - completion(error) - - } + self.cache(items, with: completion) } } } + + private func cache(_ items: [FeedItem], with completion: @escaping (Error?) -> Void) { + store.insert(items, timestamp: currentDate()) { [weak self] error in + guard self != nil else { return } + completion(error) + } + } } protocol FeedStore { From 0d5609583ad3c91cc44213b4dc9d617f7b3ddc62 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 12:53:07 +0200 Subject: [PATCH 021/159] Move `LocalFeedLoader` and `FeedStore` collaborator to its own file in production --- .../EssentialFeed.xcodeproj/project.pbxproj | 12 ++++++ .../Feed Cache/LocalFeedLoader.swift | 42 +++++++++++++++++++ .../Feed Cache/CacheFeedUseCaseTests.swift | 34 --------------- 3 files changed, 54 insertions(+), 34 deletions(-) create mode 100644 EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 254b83d..d809a82 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0D21B6DCB600813479 /* FeedLoader.swift */; }; 40B002492CF9E9DB0058D3E0 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; + 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -40,6 +41,7 @@ 080EDF0D21B6DCB600813479 /* FeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoader.swift; sourceTree = ""; }; 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; + 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -119,6 +121,7 @@ 080EDEF321B6DA7E00813479 /* EssentialFeed */ = { isa = PBXGroup; children = ( + 40B975412D9E9FB7009652B5 /* Feed Cache */, 40C57D7A2CF7C16E00518522 /* FeedApi */, 080EDEF521B6DA7E00813479 /* Info.plist */, 080EDF1021B6DFA200813479 /* Feed Feature */, @@ -154,6 +157,14 @@ path = "Feed Cache"; sourceTree = ""; }; + 40B975412D9E9FB7009652B5 /* Feed Cache */ = { + isa = PBXGroup; + children = ( + 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */, + ); + path = "Feed Cache"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -305,6 +316,7 @@ files = ( 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */, 080EDF0C21B6DAE800813479 /* FeedItem.swift in Sources */, + 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift new file mode 100644 index 0000000..50c6e7d --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -0,0 +1,42 @@ +// +// LocalFeedLoader.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 3/4/25. +// +import Foundation + +public final class LocalFeedLoader { + private let store: FeedStore + private let currentDate: () -> Date + public init(store: FeedStore, currentDate: @escaping () -> Date) { + self.store = store + self.currentDate = currentDate + } + + public func save(_ items: [FeedItem], completion: @escaping (Error?) -> Void) { + store.deleteCachedFeed { [weak self] error in + guard let self else { return } + if let cacheDeletionError = error { + completion(cacheDeletionError) + } else { + self.cache(items, with: completion) + } + } + } + + private func cache(_ items: [FeedItem], with completion: @escaping (Error?) -> Void) { + store.insert(items, timestamp: currentDate()) { [weak self] error in + guard self != nil else { return } + completion(error) + } + } +} + +public protocol FeedStore { + typealias DeletionCompletion = (Error?) -> Void + typealias InsertionCompletion = (Error?) -> Void + + func deleteCachedFeed(completion: @escaping DeletionCompletion) + func insert(_ items: [FeedItem], timestamp: Date, completion: @escaping InsertionCompletion) +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index d5aeddf..6fce264 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -8,40 +8,6 @@ import EssentialFeed import XCTest -class LocalFeedLoader { - private let store: FeedStore - private let currentDate: () -> Date - init(store: FeedStore, currentDate: @escaping () -> Date) { - self.store = store - self.currentDate = currentDate - } - - func save(_ items: [FeedItem], completion: @escaping (Error?) -> Void) { - store.deleteCachedFeed { [weak self] error in - guard let self else { return } - if let cacheDeletionError = error { - completion(cacheDeletionError) - } else { - self.cache(items, with: completion) - } - } - } - - private func cache(_ items: [FeedItem], with completion: @escaping (Error?) -> Void) { - store.insert(items, timestamp: currentDate()) { [weak self] error in - guard self != nil else { return } - completion(error) - } - } -} - -protocol FeedStore { - typealias DeletionCompletion = (Error?) -> Void - typealias InsertionCompletion = (Error?) -> Void - - func deleteCachedFeed(completion: @escaping DeletionCompletion) - func insert(_ items: [FeedItem], timestamp: Date, completion: @escaping InsertionCompletion) -} class CacheFeedUseCaseTests: XCTestCase { From e015d61ff24e3fe51f751d33a6ce8a9a9889cf67 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 12:53:33 +0200 Subject: [PATCH 022/159] Move `FeedStore` to its own file --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 ++++ .../EssentialFeed/Feed Cache/FeedStore.swift | 17 +++++++++++++++++ .../Feed Cache/LocalFeedLoader.swift | 8 +------- 3 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index d809a82..a314055 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 40B002492CF9E9DB0058D3E0 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; + 40B975452D9EA01D009652B5 /* FeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975442D9EA01D009652B5 /* FeedStore.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,6 +43,7 @@ 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; + 40B975442D9EA01D009652B5 /* FeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -161,6 +163,7 @@ isa = PBXGroup; children = ( 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */, + 40B975442D9EA01D009652B5 /* FeedStore.swift */, ); path = "Feed Cache"; sourceTree = ""; @@ -316,6 +319,7 @@ files = ( 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */, 080EDF0C21B6DAE800813479 /* FeedItem.swift in Sources */, + 40B975452D9EA01D009652B5 /* FeedStore.swift in Sources */, 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift new file mode 100644 index 0000000..f1c1e29 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -0,0 +1,17 @@ +// +// FeedStore.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 3/4/25. +// + + +import Foundation + +public protocol FeedStore { + typealias DeletionCompletion = (Error?) -> Void + typealias InsertionCompletion = (Error?) -> Void + + func deleteCachedFeed(completion: @escaping DeletionCompletion) + func insert(_ items: [FeedItem], timestamp: Date, completion: @escaping InsertionCompletion) +} \ No newline at end of file diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 50c6e7d..f7a264b 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -33,10 +33,4 @@ public final class LocalFeedLoader { } } -public protocol FeedStore { - typealias DeletionCompletion = (Error?) -> Void - typealias InsertionCompletion = (Error?) -> Void - - func deleteCachedFeed(completion: @escaping DeletionCompletion) - func insert(_ items: [FeedItem], timestamp: Date, completion: @escaping InsertionCompletion) -} + From af967ce0d1588ae11fc29b6b7a10e752a6c5e4ba Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 12:55:27 +0200 Subject: [PATCH 023/159] Add `SaveResult` typealias to protect code from potential breaking changes --- EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift | 3 +-- .../EssentialFeed/Feed Cache/LocalFeedLoader.swift | 8 ++++---- .../Feed Cache/CacheFeedUseCaseTests.swift | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift index f1c1e29..b4be4eb 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -5,7 +5,6 @@ // Created by Cristian Felipe Patiño Rojas on 3/4/25. // - import Foundation public protocol FeedStore { @@ -14,4 +13,4 @@ public protocol FeedStore { func deleteCachedFeed(completion: @escaping DeletionCompletion) func insert(_ items: [FeedItem], timestamp: Date, completion: @escaping InsertionCompletion) -} \ No newline at end of file +} diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index f7a264b..4117bf2 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -9,12 +9,14 @@ import Foundation public final class LocalFeedLoader { private let store: FeedStore private let currentDate: () -> Date + + public typealias SaveResult = Error? public init(store: FeedStore, currentDate: @escaping () -> Date) { self.store = store self.currentDate = currentDate } - public func save(_ items: [FeedItem], completion: @escaping (Error?) -> Void) { + public func save(_ items: [FeedItem], completion: @escaping (SaveResult) -> Void) { store.deleteCachedFeed { [weak self] error in guard let self else { return } if let cacheDeletionError = error { @@ -25,12 +27,10 @@ public final class LocalFeedLoader { } } - private func cache(_ items: [FeedItem], with completion: @escaping (Error?) -> Void) { + private func cache(_ items: [FeedItem], with completion: @escaping (SaveResult) -> Void) { store.insert(items, timestamp: currentDate()) { [weak self] error in guard self != nil else { return } completion(error) } } } - - diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 6fce264..6b6a8c6 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -71,7 +71,7 @@ class CacheFeedUseCaseTests: XCTestCase { func test_save_doesNotDeliverDeletionErrorAfterSUTInstanceHasBeenDeallocated() { let store = FeedStoreSpy() var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) - var receivedResults = [Error?]() + var receivedResults = [LocalFeedLoader.SaveResult]() sut?.save([uniqueItem()]) { receivedResults.append($0) } sut = nil store.completeDeletion(with: anyNSError()) @@ -81,7 +81,7 @@ class CacheFeedUseCaseTests: XCTestCase { func test_save_doesNotDeliverInsertionErrorAfterSUTInstanceHasBeenDeallocated() { let store = FeedStoreSpy() var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) - var receivedResults = [Error?]() + var receivedResults = [LocalFeedLoader.SaveResult]() sut?.save([uniqueItem()]) { receivedResults.append($0) } store.completeDeletionSuccesfully() sut = nil From 936c6133c379ddf3542321f6ec564ce9753ee177 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 13:07:46 +0200 Subject: [PATCH 024/159] Add `LocalFeedItem` data transfer representation to decouple storage frameworks from `FeedItem`data models --- .../EssentialFeed/Feed Cache/FeedStore.swift | 21 ++++++++++++++++++- .../Feed Cache/LocalFeedLoader.swift | 15 ++++++++++++- .../Feed Cache/CacheFeedUseCaseTests.swift | 14 ++++++++++--- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift index b4be4eb..ac7a78a 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -12,5 +12,24 @@ public protocol FeedStore { typealias InsertionCompletion = (Error?) -> Void func deleteCachedFeed(completion: @escaping DeletionCompletion) - func insert(_ items: [FeedItem], timestamp: Date, completion: @escaping InsertionCompletion) + func insert(_ items: [LocalFeedItem], timestamp: Date, completion: @escaping InsertionCompletion) +} + +public struct LocalFeedItem: Equatable { + + public let id: UUID + public let description: String? + public let location: String? + public let imageURL: URL + + public init( + id: UUID, + description: String?, + location: String?, + imageURL: URL) { + self.id = id + self.description = description + self.location = location + self.imageURL = imageURL + } } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 4117bf2..34b0119 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -28,9 +28,22 @@ public final class LocalFeedLoader { } private func cache(_ items: [FeedItem], with completion: @escaping (SaveResult) -> Void) { - store.insert(items, timestamp: currentDate()) { [weak self] error in + store.insert(items.toLocal(), timestamp: currentDate()) { [weak self] error in guard self != nil else { return } completion(error) } } } + +private extension Array where Element == FeedItem { + func toLocal() -> [LocalFeedItem] { + return map { + LocalFeedItem( + id: $0.id, + description: $0.description, + location: $0.location, + imageURL: $0.imageURL + ) + } + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 6b6a8c6..b36ff00 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -36,10 +36,18 @@ class CacheFeedUseCaseTests: XCTestCase { let timestamp = Date() let (sut, store) = makeSUT(currentDate: { timestamp }) let items = [uniqueItem()] + let localItems = items.map { + LocalFeedItem( + id: $0.id, + description: $0.description, + location: $0.location, + imageURL: $0.imageURL + ) + } sut.save(items) { _ in } store.completeDeletionSuccesfully() - XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed, .insert(items, timestamp)]) + XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed, .insert(localItems, timestamp)]) } func test_save_failsOnDeletionError() { @@ -145,7 +153,7 @@ class CacheFeedUseCaseTests: XCTestCase { enum ReceivedMessage: Equatable { case deleteCachedFeed - case insert([FeedItem], Date) + case insert([LocalFeedItem], Date) } func deleteCachedFeed(completion: @escaping DeletionCompletion) { @@ -161,7 +169,7 @@ class CacheFeedUseCaseTests: XCTestCase { deletionCompletions[index](nil) } - func insert(_ items: [FeedItem], timestamp: Date, completion: @escaping InsertionCompletion) { + func insert(_ items: [LocalFeedItem], timestamp: Date, completion: @escaping InsertionCompletion) { insertionCompletions.append(completion) receivedMessages.append(.insert(items, timestamp)) } From 8acc5277b4dc1208ab74dde21a73c163719f6932 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 13:11:57 +0200 Subject: [PATCH 025/159] Simplify test setup and assertions with a factory helper method --- .../Feed Cache/CacheFeedUseCaseTests.swift | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index b36ff00..4007442 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -18,16 +18,14 @@ class CacheFeedUseCaseTests: XCTestCase { func test_save_requestsCacheDeletion() { let (sut, store) = makeSUT() - let items = [uniqueItem()] - sut.save(items) { _ in } + sut.save(uniqueItems().models) { _ in } XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) } func test_save_doesNotRequestCacheInsertionOnDeletionError() { let (sut, store) = makeSUT() - let items = [uniqueItem()] let deletionError = anyNSError() - sut.save(items) { _ in } + sut.save(uniqueItems().models) { _ in } store.completeDeletion(with: deletionError) XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) } @@ -35,16 +33,9 @@ class CacheFeedUseCaseTests: XCTestCase { func test_save_requestsNewCacheInsertionWithTimestampOnSuccesfulDeletion() { let timestamp = Date() let (sut, store) = makeSUT(currentDate: { timestamp }) - let items = [uniqueItem()] - let localItems = items.map { - LocalFeedItem( - id: $0.id, - description: $0.description, - location: $0.location, - imageURL: $0.imageURL - ) - } - sut.save(items) { _ in } + let items = uniqueItems() + let localItems = items.local + sut.save(items.models) { _ in } store.completeDeletionSuccesfully() XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed, .insert(localItems, timestamp)]) @@ -80,7 +71,7 @@ class CacheFeedUseCaseTests: XCTestCase { let store = FeedStoreSpy() var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) var receivedResults = [LocalFeedLoader.SaveResult]() - sut?.save([uniqueItem()]) { receivedResults.append($0) } + sut?.save(uniqueItems().models) { receivedResults.append($0) } sut = nil store.completeDeletion(with: anyNSError()) XCTAssertTrue(receivedResults.isEmpty) @@ -90,7 +81,7 @@ class CacheFeedUseCaseTests: XCTestCase { let store = FeedStoreSpy() var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) var receivedResults = [LocalFeedLoader.SaveResult]() - sut?.save([uniqueItem()]) { receivedResults.append($0) } + sut?.save(uniqueItems().models) { receivedResults.append($0) } store.completeDeletionSuccesfully() sut = nil store.completeInsertion(with: anyNSError()) @@ -120,7 +111,7 @@ class CacheFeedUseCaseTests: XCTestCase { ) { let exp = expectation(description: "Wait for save completion") var receivedError: Error? - sut.save([uniqueItem()]) { error in + sut.save(uniqueItems().models) { error in receivedError = error exp.fulfill() } @@ -136,6 +127,19 @@ class CacheFeedUseCaseTests: XCTestCase { ) } + func uniqueItems() -> (models: [FeedItem], local: [LocalFeedItem]) { + let models = [uniqueItem(), uniqueItem()] + let local = models.map { + LocalFeedItem( + id: $0.id, + description: $0.description, + location: $0.location, + imageURL: $0.imageURL + ) + } + return (models, local) + } + func uniqueItem() -> FeedItem { return FeedItem(id: UUID(), description: "any", location: "any", imageURL: anyURL()) } From 21c324505de86c4722bba01efb410e142c2af530 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 13:23:22 +0200 Subject: [PATCH 026/159] Add `RemoteFeedItem` data transfer representation to decouple the items mapper from `FeedItem` data models --- .../FeedApi/FeedItemsMapper.swift | 35 ++++++------------- .../FeedApi/RemoteFeedLoader.swift | 24 ++++++++++++- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift b/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift index 22505bd..b462507 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift @@ -6,42 +6,29 @@ // import Foundation +internal struct RemoteFeedItem: Decodable { + internal let id: UUID + internal let description: String? + internal let location: String? + internal let image: URL +} + internal enum FeedItemsMapper { struct Root: Decodable { - let items: [Item] - - var feed: [FeedItem] { - items.map(\.item) - } - } - - struct Item: Decodable { - let id: UUID - let description: String? - let location: String? - let image: URL - - var item: FeedItem { - FeedItem( - id: id, - description: description, - location: location, - imageURL: image) - } + let items: [RemoteFeedItem] } private static let jsonDecoder = JSONDecoder() - private static let OK_200 = 200 - internal static func map(_ data: Data, from response: HTTPURLResponse) -> RemoteFeedLoader.Result { + internal static func map(_ data: Data, from response: HTTPURLResponse) throws -> [RemoteFeedItem] { guard response.statusCode == OK_200, let root = try? jsonDecoder.decode(Root.self, from: data) else { - return .failure(RemoteFeedLoader.Error.invalidData) + throw RemoteFeedLoader.Error.invalidData } - return .success(root.feed) + return root.items } } diff --git a/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift b/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift index eccd2b0..aa20214 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift @@ -28,11 +28,33 @@ public final class RemoteFeedLoader: FeedLoader { guard self != nil else { return } switch result { case let .success(data, response): - completion(FeedItemsMapper.map(data, from: response)) + completion(Self.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.toModels()) + } catch { + return .failure(error) + } + } } + +private extension Array where Element == RemoteFeedItem { + func toModels() -> [FeedItem] { + return map { + FeedItem( + id: $0.id, + description: $0.description, + location: $0.location, + imageURL: $0.image + ) + } + } +} From cf6df4ea2b34efa8eb5fcf633a647fe2a47d00f8 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 13:25:02 +0200 Subject: [PATCH 027/159] Move `RemoteFeedItem` to its own file --- .../EssentialFeed.xcodeproj/project.pbxproj | 1 + .../EssentialFeed/FeedApi/FeedItemsMapper.swift | 7 +------ .../EssentialFeed/FeedApi/RemoteFeedItem.swift | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 EssentialFeed/EssentialFeed/FeedApi/RemoteFeedItem.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index a314055..423d281 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ membershipExceptions = ( FeedItemsMapper.swift, HTTPClient.swift, + RemoteFeedItem.swift, RemoteFeedLoader.swift, URLSessionHTTPClient.swift, ); diff --git a/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift b/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift index b462507..71a6644 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift @@ -6,12 +6,7 @@ // import Foundation -internal struct RemoteFeedItem: Decodable { - internal let id: UUID - internal let description: String? - internal let location: String? - internal let image: URL -} + internal enum FeedItemsMapper { diff --git a/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedItem.swift b/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedItem.swift new file mode 100644 index 0000000..fff12ee --- /dev/null +++ b/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedItem.swift @@ -0,0 +1,14 @@ +// +// RemoteFeedItem.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 3/4/25. +// +import Foundation + +internal struct RemoteFeedItem: Decodable { + internal let id: UUID + internal let description: String? + internal let location: String? + internal let image: URL +} From 0436d6f64d5080747afa95f34b166f5992634ff8 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 13:25:43 +0200 Subject: [PATCH 028/159] Move `LocalFeedItem` to its own file --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 +++ .../EssentialFeed/Feed Cache/FeedStore.swift | 19 +------------ .../Feed Cache/LocalFeedItem.swift | 28 +++++++++++++++++++ 3 files changed, 33 insertions(+), 18 deletions(-) create mode 100644 EssentialFeed/EssentialFeed/Feed Cache/LocalFeedItem.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 423d281..e15831b 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; 40B975452D9EA01D009652B5 /* FeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975442D9EA01D009652B5 /* FeedStore.swift */; }; + 40B975492D9EA7A2009652B5 /* LocalFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975482D9EA7A2009652B5 /* LocalFeedItem.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -44,6 +45,7 @@ 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; 40B975442D9EA01D009652B5 /* FeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStore.swift; sourceTree = ""; }; + 40B975482D9EA7A2009652B5 /* LocalFeedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedItem.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -165,6 +167,7 @@ children = ( 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */, 40B975442D9EA01D009652B5 /* FeedStore.swift */, + 40B975482D9EA7A2009652B5 /* LocalFeedItem.swift */, ); path = "Feed Cache"; sourceTree = ""; @@ -320,6 +323,7 @@ files = ( 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */, 080EDF0C21B6DAE800813479 /* FeedItem.swift in Sources */, + 40B975492D9EA7A2009652B5 /* LocalFeedItem.swift in Sources */, 40B975452D9EA01D009652B5 /* FeedStore.swift in Sources */, 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */, ); diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift index ac7a78a..52b4343 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -15,21 +15,4 @@ public protocol FeedStore { func insert(_ items: [LocalFeedItem], timestamp: Date, completion: @escaping InsertionCompletion) } -public struct LocalFeedItem: Equatable { - - public let id: UUID - public let description: String? - public let location: String? - public let imageURL: URL - - public init( - id: UUID, - description: String?, - location: String?, - imageURL: URL) { - self.id = id - self.description = description - self.location = location - self.imageURL = imageURL - } -} + diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedItem.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedItem.swift new file mode 100644 index 0000000..ee95721 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedItem.swift @@ -0,0 +1,28 @@ +// +// LocalFeedItem.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 3/4/25. +// + + +import Foundation + +public struct LocalFeedItem: Equatable { + + public let id: UUID + public let description: String? + public let location: String? + public let imageURL: URL + + public init( + id: UUID, + description: String?, + location: String?, + imageURL: URL) { + self.id = id + self.description = description + self.location = location + self.imageURL = imageURL + } +} \ No newline at end of file From 6c5628825b15b79ffab52a46ec3dc47f8d5e8cb5 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 13:34:15 +0200 Subject: [PATCH 029/159] Remove references of "items" in favor of "images" which is a domain term used by domain experts in the specs --- .../EssentialFeed.xcodeproj/project.pbxproj | 16 ++++----- .../EssentialFeed/Feed Cache/FeedStore.swift | 2 +- ...calFeedItem.swift => LocalFeedImage.swift} | 12 +++---- .../Feed Cache/LocalFeedLoader.swift | 16 ++++----- .../{FeedItem.swift => FeedImage.swift} | 8 ++--- .../Feed Feature/FeedLoader.swift | 2 +- .../FeedApi/RemoteFeedLoader.swift | 6 ++-- .../EssentialFeedAPIEndToEndTests.swift | 26 +++++++------- .../LoadFeedFromRemoteUseCaseTests.swift | 6 ++-- .../Feed Cache/CacheFeedUseCaseTests.swift | 36 +++++++++---------- 10 files changed, 65 insertions(+), 65 deletions(-) rename EssentialFeed/EssentialFeed/Feed Cache/{LocalFeedItem.swift => LocalFeedImage.swift} (72%) rename EssentialFeed/EssentialFeed/Feed Feature/{FeedItem.swift => FeedImage.swift} (75%) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index e15831b..3b16bb0 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -8,13 +8,13 @@ /* Begin PBXBuildFile section */ 080EDEFB21B6DA7E00813479 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; - 080EDF0C21B6DAE800813479 /* FeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0B21B6DAE800813479 /* FeedItem.swift */; }; + 080EDF0C21B6DAE800813479 /* FeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0B21B6DAE800813479 /* FeedImage.swift */; }; 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0D21B6DCB600813479 /* FeedLoader.swift */; }; 40B002492CF9E9DB0058D3E0 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; 40B975452D9EA01D009652B5 /* FeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975442D9EA01D009652B5 /* FeedStore.swift */; }; - 40B975492D9EA7A2009652B5 /* LocalFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975482D9EA7A2009652B5 /* LocalFeedItem.swift */; }; + 40B975492D9EA7A2009652B5 /* LocalFeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975482D9EA7A2009652B5 /* LocalFeedImage.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -39,13 +39,13 @@ 080EDEF521B6DA7E00813479 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 080EDEFA21B6DA7E00813479 /* EssentialFeedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 080EDF0121B6DA7E00813479 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 080EDF0B21B6DAE800813479 /* FeedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItem.swift; sourceTree = ""; }; + 080EDF0B21B6DAE800813479 /* FeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImage.swift; sourceTree = ""; }; 080EDF0D21B6DCB600813479 /* FeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoader.swift; sourceTree = ""; }; 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; 40B975442D9EA01D009652B5 /* FeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStore.swift; sourceTree = ""; }; - 40B975482D9EA7A2009652B5 /* LocalFeedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedItem.swift; sourceTree = ""; }; + 40B975482D9EA7A2009652B5 /* LocalFeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedImage.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -148,7 +148,7 @@ 080EDF1021B6DFA200813479 /* Feed Feature */ = { isa = PBXGroup; children = ( - 080EDF0B21B6DAE800813479 /* FeedItem.swift */, + 080EDF0B21B6DAE800813479 /* FeedImage.swift */, 080EDF0D21B6DCB600813479 /* FeedLoader.swift */, ); path = "Feed Feature"; @@ -167,7 +167,7 @@ children = ( 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */, 40B975442D9EA01D009652B5 /* FeedStore.swift */, - 40B975482D9EA7A2009652B5 /* LocalFeedItem.swift */, + 40B975482D9EA7A2009652B5 /* LocalFeedImage.swift */, ); path = "Feed Cache"; sourceTree = ""; @@ -322,8 +322,8 @@ buildActionMask = 2147483647; files = ( 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */, - 080EDF0C21B6DAE800813479 /* FeedItem.swift in Sources */, - 40B975492D9EA7A2009652B5 /* LocalFeedItem.swift in Sources */, + 080EDF0C21B6DAE800813479 /* FeedImage.swift in Sources */, + 40B975492D9EA7A2009652B5 /* LocalFeedImage.swift in Sources */, 40B975452D9EA01D009652B5 /* FeedStore.swift in Sources */, 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */, ); diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift index 52b4343..69a8175 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -12,7 +12,7 @@ public protocol FeedStore { typealias InsertionCompletion = (Error?) -> Void func deleteCachedFeed(completion: @escaping DeletionCompletion) - func insert(_ items: [LocalFeedItem], timestamp: Date, completion: @escaping InsertionCompletion) + func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedItem.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift similarity index 72% rename from EssentialFeed/EssentialFeed/Feed Cache/LocalFeedItem.swift rename to EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift index ee95721..b94cfe9 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedItem.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift @@ -1,5 +1,5 @@ // -// LocalFeedItem.swift +// LocalFeedImage.swift // EssentialFeed // // Created by Cristian Felipe Patiño Rojas on 3/4/25. @@ -8,21 +8,21 @@ import Foundation -public struct LocalFeedItem: Equatable { +public struct LocalFeedImage: Equatable { public let id: UUID public let description: String? public let location: String? - public let imageURL: URL + public let url: URL public init( id: UUID, description: String?, location: String?, - imageURL: URL) { + url: URL) { self.id = id self.description = description self.location = location - self.imageURL = imageURL + self.url = url } -} \ No newline at end of file +} diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 34b0119..ff70e61 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -16,33 +16,33 @@ public final class LocalFeedLoader { self.currentDate = currentDate } - public func save(_ items: [FeedItem], completion: @escaping (SaveResult) -> Void) { + public func save(_ feed: [FeedImage], completion: @escaping (SaveResult) -> Void) { store.deleteCachedFeed { [weak self] error in guard let self else { return } if let cacheDeletionError = error { completion(cacheDeletionError) } else { - self.cache(items, with: completion) + self.cache(feed, with: completion) } } } - private func cache(_ items: [FeedItem], with completion: @escaping (SaveResult) -> Void) { - store.insert(items.toLocal(), timestamp: currentDate()) { [weak self] error in + private func cache(_ feed: [FeedImage], with completion: @escaping (SaveResult) -> Void) { + store.insert(feed.toLocal(), timestamp: currentDate()) { [weak self] error in guard self != nil else { return } completion(error) } } } -private extension Array where Element == FeedItem { - func toLocal() -> [LocalFeedItem] { +private extension Array where Element == FeedImage { + func toLocal() -> [LocalFeedImage] { return map { - LocalFeedItem( + LocalFeedImage( id: $0.id, description: $0.description, location: $0.location, - imageURL: $0.imageURL + url: $0.url ) } } diff --git a/EssentialFeed/EssentialFeed/Feed Feature/FeedItem.swift b/EssentialFeed/EssentialFeed/Feed Feature/FeedImage.swift similarity index 75% rename from EssentialFeed/EssentialFeed/Feed Feature/FeedItem.swift rename to EssentialFeed/EssentialFeed/Feed Feature/FeedImage.swift index 11f5d5d..df7e179 100644 --- a/EssentialFeed/EssentialFeed/Feed Feature/FeedItem.swift +++ b/EssentialFeed/EssentialFeed/Feed Feature/FeedImage.swift @@ -4,21 +4,21 @@ import Foundation -public struct FeedItem: Equatable { +public struct FeedImage: Equatable { public let id: UUID public let description: String? public let location: String? - public let imageURL: URL + public let url: URL public init( id: UUID, description: String?, location: String?, - imageURL: URL) { + url: URL) { self.id = id self.description = description self.location = location - self.imageURL = imageURL + self.url = url } } diff --git a/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift index 7f61af7..acd3d92 100644 --- a/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift @@ -5,7 +5,7 @@ import Foundation public enum LoadFeedResult { - case success([FeedItem]) + case success([FeedImage]) case failure(Error) } diff --git a/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift b/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift index aa20214..5f2c838 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift @@ -47,13 +47,13 @@ public final class RemoteFeedLoader: FeedLoader { private extension Array where Element == RemoteFeedItem { - func toModels() -> [FeedItem] { + func toModels() -> [FeedImage] { return map { - FeedItem( + FeedImage( id: $0.id, description: $0.description, location: $0.location, - imageURL: $0.image + url: $0.image ) } } diff --git a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift index 9bc4bb2..be50db2 100644 --- a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift +++ b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift @@ -12,16 +12,16 @@ final class EssentialFeedAPIEndToEndTests: XCTestCase { func test_endToEndTestServerGETFeedResult_matchesFixedTestAccountData() { switch getFeedResult() { - case let .success(items)?: - XCTAssertEqual(items.count, 8, "Expected 8 items in the test account feed") - XCTAssertEqual(items[0], expectedItem(at: 0)) - XCTAssertEqual(items[1], expectedItem(at: 1)) - XCTAssertEqual(items[2], expectedItem(at: 2)) - XCTAssertEqual(items[3], expectedItem(at: 3)) - XCTAssertEqual(items[4], expectedItem(at: 4)) - XCTAssertEqual(items[5], expectedItem(at: 5)) - XCTAssertEqual(items[6], expectedItem(at: 6)) - XCTAssertEqual(items[7], expectedItem(at: 7)) + case let .success(imageFeed)?: + XCTAssertEqual(imageFeed.count, 8, "Expected 8 images in the test account feed") + XCTAssertEqual(imageFeed[0], expectedImage(at: 0)) + XCTAssertEqual(imageFeed[1], expectedImage(at: 1)) + XCTAssertEqual(imageFeed[2], expectedImage(at: 2)) + XCTAssertEqual(imageFeed[3], expectedImage(at: 3)) + XCTAssertEqual(imageFeed[4], expectedImage(at: 4)) + XCTAssertEqual(imageFeed[5], expectedImage(at: 5)) + XCTAssertEqual(imageFeed[6], expectedImage(at: 6)) + XCTAssertEqual(imageFeed[7], expectedImage(at: 7)) case let .failure(error)?: XCTFail("Expected succesful feed result, got \(error) instead") default: @@ -57,12 +57,12 @@ final class EssentialFeedAPIEndToEndTests: XCTestCase { // MARK: - Helpers - private func expectedItem(at index: Int) -> FeedItem { - return FeedItem( + private func expectedImage(at index: Int) -> FeedImage { + return FeedImage( id: id(at: index), description: description(at: index), location: location(at: index), - imageURL: imageURL(at: index)) + url: imageURL(at: index)) } private func id(at index: Int) -> UUID { diff --git a/EssentialFeed/EssentialFeedTests/Feed Api/LoadFeedFromRemoteUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Api/LoadFeedFromRemoteUseCaseTests.swift index a09fd5a..03fa678 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Api/LoadFeedFromRemoteUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Api/LoadFeedFromRemoteUseCaseTests.swift @@ -122,12 +122,12 @@ class LoadFeedFromRemoteUseCaseTests: XCTestCase { .failure(error) } - private func makeItem(id: UUID, description: String? = nil, location: String? = nil, imageURL: URL) -> (model: FeedItem, json: [String: Any]) { - let item = FeedItem( + 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, - imageURL: imageURL) + url: imageURL) let json = [ "id": id.uuidString, "description": description, diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 4007442..5f4bcf3 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -18,14 +18,14 @@ class CacheFeedUseCaseTests: XCTestCase { func test_save_requestsCacheDeletion() { let (sut, store) = makeSUT() - sut.save(uniqueItems().models) { _ in } + sut.save(uniqueImageFeed().models) { _ in } XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) } func test_save_doesNotRequestCacheInsertionOnDeletionError() { let (sut, store) = makeSUT() let deletionError = anyNSError() - sut.save(uniqueItems().models) { _ in } + sut.save(uniqueImageFeed().models) { _ in } store.completeDeletion(with: deletionError) XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) } @@ -33,12 +33,12 @@ class CacheFeedUseCaseTests: XCTestCase { func test_save_requestsNewCacheInsertionWithTimestampOnSuccesfulDeletion() { let timestamp = Date() let (sut, store) = makeSUT(currentDate: { timestamp }) - let items = uniqueItems() - let localItems = items.local - sut.save(items.models) { _ in } + let feed = uniqueImageFeed() + let localFeed = feed.local + sut.save(feed.models) { _ in } store.completeDeletionSuccesfully() - XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed, .insert(localItems, timestamp)]) + XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed, .insert(localFeed, timestamp)]) } func test_save_failsOnDeletionError() { @@ -71,7 +71,7 @@ class CacheFeedUseCaseTests: XCTestCase { let store = FeedStoreSpy() var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) var receivedResults = [LocalFeedLoader.SaveResult]() - sut?.save(uniqueItems().models) { receivedResults.append($0) } + sut?.save(uniqueImageFeed().models) { receivedResults.append($0) } sut = nil store.completeDeletion(with: anyNSError()) XCTAssertTrue(receivedResults.isEmpty) @@ -81,7 +81,7 @@ class CacheFeedUseCaseTests: XCTestCase { let store = FeedStoreSpy() var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) var receivedResults = [LocalFeedLoader.SaveResult]() - sut?.save(uniqueItems().models) { receivedResults.append($0) } + sut?.save(uniqueImageFeed().models) { receivedResults.append($0) } store.completeDeletionSuccesfully() sut = nil store.completeInsertion(with: anyNSError()) @@ -111,7 +111,7 @@ class CacheFeedUseCaseTests: XCTestCase { ) { let exp = expectation(description: "Wait for save completion") var receivedError: Error? - sut.save(uniqueItems().models) { error in + sut.save(uniqueImageFeed().models) { error in receivedError = error exp.fulfill() } @@ -127,21 +127,21 @@ class CacheFeedUseCaseTests: XCTestCase { ) } - func uniqueItems() -> (models: [FeedItem], local: [LocalFeedItem]) { - let models = [uniqueItem(), uniqueItem()] + func uniqueImageFeed() -> (models: [FeedImage], local: [LocalFeedImage]) { + let models = [uniqueImage(), uniqueImage()] let local = models.map { - LocalFeedItem( + LocalFeedImage( id: $0.id, description: $0.description, location: $0.location, - imageURL: $0.imageURL + url: $0.url ) } return (models, local) } - func uniqueItem() -> FeedItem { - return FeedItem(id: UUID(), description: "any", location: "any", imageURL: anyURL()) + func uniqueImage() -> FeedImage { + return FeedImage(id: UUID(), description: "any", location: "any", url: anyURL()) } private func anyURL() -> URL { @@ -157,7 +157,7 @@ class CacheFeedUseCaseTests: XCTestCase { enum ReceivedMessage: Equatable { case deleteCachedFeed - case insert([LocalFeedItem], Date) + case insert([LocalFeedImage], Date) } func deleteCachedFeed(completion: @escaping DeletionCompletion) { @@ -173,9 +173,9 @@ class CacheFeedUseCaseTests: XCTestCase { deletionCompletions[index](nil) } - func insert(_ items: [LocalFeedItem], timestamp: Date, completion: @escaping InsertionCompletion) { + func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { insertionCompletions.append(completion) - receivedMessages.append(.insert(items, timestamp)) + receivedMessages.append(.insert(feed, timestamp)) } func completeInsertion(with error: Error, at index: Int = 0) { From 13f3ef5f3786678f321c325b9045b051f9527e68 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 15:14:23 +0200 Subject: [PATCH 030/159] `LocalFeedLoader` does not message store upon creation (before loading the feed from the cache store) --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 ++ .../LoadFeedFromCacheUseCaseTests.swift | 68 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 3b16bb0..f5bc65b 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; 40B975452D9EA01D009652B5 /* FeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975442D9EA01D009652B5 /* FeedStore.swift */; }; 40B975492D9EA7A2009652B5 /* LocalFeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975482D9EA7A2009652B5 /* LocalFeedImage.swift */; }; + 40B9754B2D9EC061009652B5 /* LoadFeedFromCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9754A2D9EC05C009652B5 /* LoadFeedFromCacheUseCaseTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -46,6 +47,7 @@ 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; 40B975442D9EA01D009652B5 /* FeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStore.swift; sourceTree = ""; }; 40B975482D9EA7A2009652B5 /* LocalFeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedImage.swift; sourceTree = ""; }; + 40B9754A2D9EC05C009652B5 /* LoadFeedFromCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadFeedFromCacheUseCaseTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -157,6 +159,7 @@ 40B975392D9E7AD3009652B5 /* Feed Cache */ = { isa = PBXGroup; children = ( + 40B9754A2D9EC05C009652B5 /* LoadFeedFromCacheUseCaseTests.swift */, 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */, ); path = "Feed Cache"; @@ -334,6 +337,7 @@ buildActionMask = 2147483647; files = ( 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */, + 40B9754B2D9EC061009652B5 /* LoadFeedFromCacheUseCaseTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift new file mode 100644 index 0000000..6f25315 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -0,0 +1,68 @@ +// +// LoadFeedFromCacheUseCaseTests.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 3/4/25. +// + +import XCTest +import EssentialFeed + +class LoadFeedFromCacheUseCaseTests: XCTestCase { + + func test_doesNotMessageStoreUponCreation() { + let (_, store) = makeSUT() + XCTAssertEqual(store.receivedMessages, []) + } + + // MARK: - Helpers + + private func makeSUT( + currentDate: @escaping () -> Date = Date.init, + file: StaticString = #file, + line: UInt = #line + ) -> (sut: LocalFeedLoader, store: FeedStoreSpy) { + let store = FeedStoreSpy() + let sut = LocalFeedLoader(store: store, currentDate: currentDate) + trackForMemoryLeaks(store, file: file, line: line) + trackForMemoryLeaks(sut, file: file, line: line) + return (sut, store) + } + + private class FeedStoreSpy: FeedStore { + private var deletionCompletions = [DeletionCompletion]() + private var insertionCompletions = [InsertionCompletion]() + private(set) var receivedMessages = [ReceivedMessage]() + + enum ReceivedMessage: Equatable { + case deleteCachedFeed + case insert([LocalFeedImage], Date) + } + + func deleteCachedFeed(completion: @escaping DeletionCompletion) { + deletionCompletions.append(completion) + receivedMessages.append(.deleteCachedFeed) + } + + func completeDeletion(with error: Error, at index: Int = 0) { + deletionCompletions[index](error) + } + + func completeDeletionSuccesfully(at index: Int = 0) { + deletionCompletions[index](nil) + } + + func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { + insertionCompletions.append(completion) + receivedMessages.append(.insert(feed, timestamp)) + } + + func completeInsertion(with error: Error, at index: Int = 0) { + insertionCompletions[index](error) + } + + func completeInsertionSuccesfully(at index: Int = 0) { + insertionCompletions[index](nil) + } + } +} From 43ac6f4095c56addcd01f3bb12f51017fc14f9fc Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 15:16:15 +0200 Subject: [PATCH 031/159] Extract `FeedCacheSpy` into a shared scope to remove duplication --- .../EssentialFeed.xcodeproj/project.pbxproj | 12 +++++ .../Feed Cache/CacheFeedUseCaseTests.swift | 39 +-------------- .../Feed Cache/Helpers/FeedStoreSpy.swift | 47 +++++++++++++++++++ .../LoadFeedFromCacheUseCaseTests.swift | 37 --------------- 4 files changed, 60 insertions(+), 75 deletions(-) create mode 100644 EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index f5bc65b..4285979 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 40B975452D9EA01D009652B5 /* FeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975442D9EA01D009652B5 /* FeedStore.swift */; }; 40B975492D9EA7A2009652B5 /* LocalFeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975482D9EA7A2009652B5 /* LocalFeedImage.swift */; }; 40B9754B2D9EC061009652B5 /* LoadFeedFromCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9754A2D9EC05C009652B5 /* LoadFeedFromCacheUseCaseTests.swift */; }; + 40B9754E2D9EC15A009652B5 /* FeedStoreSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9754D2D9EC15A009652B5 /* FeedStoreSpy.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -48,6 +49,7 @@ 40B975442D9EA01D009652B5 /* FeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStore.swift; sourceTree = ""; }; 40B975482D9EA7A2009652B5 /* LocalFeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedImage.swift; sourceTree = ""; }; 40B9754A2D9EC05C009652B5 /* LoadFeedFromCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadFeedFromCacheUseCaseTests.swift; sourceTree = ""; }; + 40B9754D2D9EC15A009652B5 /* FeedStoreSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStoreSpy.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -159,6 +161,7 @@ 40B975392D9E7AD3009652B5 /* Feed Cache */ = { isa = PBXGroup; children = ( + 40B9754C2D9EC147009652B5 /* Helpers */, 40B9754A2D9EC05C009652B5 /* LoadFeedFromCacheUseCaseTests.swift */, 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */, ); @@ -175,6 +178,14 @@ path = "Feed Cache"; sourceTree = ""; }; + 40B9754C2D9EC147009652B5 /* Helpers */ = { + isa = PBXGroup; + children = ( + 40B9754D2D9EC15A009652B5 /* FeedStoreSpy.swift */, + ); + path = Helpers; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -336,6 +347,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 40B9754E2D9EC15A009652B5 /* FeedStoreSpy.swift in Sources */, 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */, 40B9754B2D9EC061009652B5 /* LoadFeedFromCacheUseCaseTests.swift in Sources */, ); diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 5f4bcf3..dab3309 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -148,42 +148,5 @@ class CacheFeedUseCaseTests: XCTestCase { URL(string: "http://any-url.com")! } - private func anyNSError() -> NSError { NSError(domain: "any error", code: 0) } - - private class FeedStoreSpy: FeedStore { - private var deletionCompletions = [DeletionCompletion]() - private var insertionCompletions = [InsertionCompletion]() - private(set) var receivedMessages = [ReceivedMessage]() - - enum ReceivedMessage: Equatable { - case deleteCachedFeed - case insert([LocalFeedImage], Date) - } - - func deleteCachedFeed(completion: @escaping DeletionCompletion) { - deletionCompletions.append(completion) - receivedMessages.append(.deleteCachedFeed) - } - - func completeDeletion(with error: Error, at index: Int = 0) { - deletionCompletions[index](error) - } - - func completeDeletionSuccesfully(at index: Int = 0) { - deletionCompletions[index](nil) - } - - func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { - insertionCompletions.append(completion) - receivedMessages.append(.insert(feed, timestamp)) - } - - func completeInsertion(with error: Error, at index: Int = 0) { - insertionCompletions[index](error) - } - - func completeInsertionSuccesfully(at index: Int = 0) { - insertionCompletions[index](nil) - } - } + private func anyNSError() -> NSError { NSError(domain: "any error", code: 0) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift new file mode 100644 index 0000000..82327bb --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift @@ -0,0 +1,47 @@ +// +// FeedStoreSpy.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 3/4/25. +// + + +import XCTest +import EssentialFeed + +class FeedStoreSpy: FeedStore { + private var deletionCompletions = [DeletionCompletion]() + private var insertionCompletions = [InsertionCompletion]() + private(set) var receivedMessages = [ReceivedMessage]() + + enum ReceivedMessage: Equatable { + case deleteCachedFeed + case insert([LocalFeedImage], Date) + } + + func deleteCachedFeed(completion: @escaping DeletionCompletion) { + deletionCompletions.append(completion) + receivedMessages.append(.deleteCachedFeed) + } + + func completeDeletion(with error: Error, at index: Int = 0) { + deletionCompletions[index](error) + } + + func completeDeletionSuccesfully(at index: Int = 0) { + deletionCompletions[index](nil) + } + + func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { + insertionCompletions.append(completion) + receivedMessages.append(.insert(feed, timestamp)) + } + + func completeInsertion(with error: Error, at index: Int = 0) { + insertionCompletions[index](error) + } + + func completeInsertionSuccesfully(at index: Int = 0) { + insertionCompletions[index](nil) + } + } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 6f25315..5c2a214 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -28,41 +28,4 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { trackForMemoryLeaks(sut, file: file, line: line) return (sut, store) } - - private class FeedStoreSpy: FeedStore { - private var deletionCompletions = [DeletionCompletion]() - private var insertionCompletions = [InsertionCompletion]() - private(set) var receivedMessages = [ReceivedMessage]() - - enum ReceivedMessage: Equatable { - case deleteCachedFeed - case insert([LocalFeedImage], Date) - } - - func deleteCachedFeed(completion: @escaping DeletionCompletion) { - deletionCompletions.append(completion) - receivedMessages.append(.deleteCachedFeed) - } - - func completeDeletion(with error: Error, at index: Int = 0) { - deletionCompletions[index](error) - } - - func completeDeletionSuccesfully(at index: Int = 0) { - deletionCompletions[index](nil) - } - - func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { - insertionCompletions.append(completion) - receivedMessages.append(.insert(feed, timestamp)) - } - - func completeInsertion(with error: Error, at index: Int = 0) { - insertionCompletions[index](error) - } - - func completeInsertionSuccesfully(at index: Int = 0) { - insertionCompletions[index](nil) - } - } } From 660e80161aca60314e654d2fbbb93e2f810b8fa1 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 15:19:48 +0200 Subject: [PATCH 032/159] Load command requests cache retrieval --- .../EssentialFeed/Feed Cache/FeedStore.swift | 1 + .../Feed Cache/LocalFeedLoader.swift | 4 + .../Feed Cache/Helpers/FeedStoreSpy.swift | 74 ++++++++++--------- .../LoadFeedFromCacheUseCaseTests.swift | 6 ++ 4 files changed, 51 insertions(+), 34 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift index 69a8175..9615a77 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -13,6 +13,7 @@ public protocol FeedStore { func deleteCachedFeed(completion: @escaping DeletionCompletion) func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) + func retrieve() } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index ff70e61..67c3256 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -16,6 +16,10 @@ public final class LocalFeedLoader { self.currentDate = currentDate } + public func load() { + store.retrieve() + } + public func save(_ feed: [FeedImage], completion: @escaping (SaveResult) -> Void) { store.deleteCachedFeed { [weak self] error in guard let self else { return } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift index 82327bb..54d56e2 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift @@ -10,38 +10,44 @@ import XCTest import EssentialFeed class FeedStoreSpy: FeedStore { - private var deletionCompletions = [DeletionCompletion]() - private var insertionCompletions = [InsertionCompletion]() - private(set) var receivedMessages = [ReceivedMessage]() - - enum ReceivedMessage: Equatable { - case deleteCachedFeed - case insert([LocalFeedImage], Date) - } - - func deleteCachedFeed(completion: @escaping DeletionCompletion) { - deletionCompletions.append(completion) - receivedMessages.append(.deleteCachedFeed) - } - - func completeDeletion(with error: Error, at index: Int = 0) { - deletionCompletions[index](error) - } - - func completeDeletionSuccesfully(at index: Int = 0) { - deletionCompletions[index](nil) - } - - func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { - insertionCompletions.append(completion) - receivedMessages.append(.insert(feed, timestamp)) - } - - func completeInsertion(with error: Error, at index: Int = 0) { - insertionCompletions[index](error) - } - - func completeInsertionSuccesfully(at index: Int = 0) { - insertionCompletions[index](nil) - } + private var deletionCompletions = [DeletionCompletion]() + private var insertionCompletions = [InsertionCompletion]() + private(set) var receivedMessages = [ReceivedMessage]() + + enum ReceivedMessage: Equatable { + case deleteCachedFeed + case insert([LocalFeedImage], Date) + case retrieve } + + func deleteCachedFeed(completion: @escaping DeletionCompletion) { + deletionCompletions.append(completion) + receivedMessages.append(.deleteCachedFeed) + } + + func completeDeletion(with error: Error, at index: Int = 0) { + deletionCompletions[index](error) + } + + func completeDeletionSuccesfully(at index: Int = 0) { + deletionCompletions[index](nil) + } + + + func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { + insertionCompletions.append(completion) + receivedMessages.append(.insert(feed, timestamp)) + } + + func completeInsertion(with error: Error, at index: Int = 0) { + insertionCompletions[index](error) + } + + func completeInsertionSuccesfully(at index: Int = 0) { + insertionCompletions[index](nil) + } + + func retrieve() { + receivedMessages.append(.retrieve) + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 5c2a214..a4ed601 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -15,6 +15,12 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, []) } + func test_load_requestsCacheRetrieval() { + let (sut, store) = makeSUT() + sut.load() + XCTAssertEqual(store.receivedMessages, [.retrieve]) + } + // MARK: - Helpers private func makeSUT( From ff82695274d4420eb3fe3d187880df34f743d480 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 15:27:48 +0200 Subject: [PATCH 033/159] Load command fails on retrieval error --- .../EssentialFeed/Feed Cache/FeedStore.swift | 3 ++- .../Feed Cache/LocalFeedLoader.swift | 4 ++-- .../Feed Cache/Helpers/FeedStoreSpy.swift | 9 +++++++- .../LoadFeedFromCacheUseCaseTests.swift | 21 ++++++++++++++++++- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift index 9615a77..1425707 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -10,10 +10,11 @@ import Foundation public protocol FeedStore { typealias DeletionCompletion = (Error?) -> Void typealias InsertionCompletion = (Error?) -> Void + typealias RetrievalCompletion = (Error?) -> Void func deleteCachedFeed(completion: @escaping DeletionCompletion) func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) - func retrieve() + func retrieve(completion: @escaping RetrievalCompletion) } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 67c3256..1d5d2c3 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -16,8 +16,8 @@ public final class LocalFeedLoader { self.currentDate = currentDate } - public func load() { - store.retrieve() + public func load(completion: @escaping (Error?) -> Void) { + store.retrieve(completion: completion) } public func save(_ feed: [FeedImage], completion: @escaping (SaveResult) -> Void) { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift index 54d56e2..87e60ab 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift @@ -12,6 +12,8 @@ import EssentialFeed class FeedStoreSpy: FeedStore { private var deletionCompletions = [DeletionCompletion]() private var insertionCompletions = [InsertionCompletion]() + private var retrievalCompletions = [RetrievalCompletion]() + private(set) var receivedMessages = [ReceivedMessage]() enum ReceivedMessage: Equatable { @@ -47,7 +49,12 @@ class FeedStoreSpy: FeedStore { insertionCompletions[index](nil) } - func retrieve() { + func retrieve(completion: @escaping RetrievalCompletion) { + retrievalCompletions.append(completion) receivedMessages.append(.retrieve) } + + func completeRetrieval(with error: Error, at index: Int = 0) { + retrievalCompletions[index](error) + } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index a4ed601..3c3a924 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -17,10 +17,27 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { func test_load_requestsCacheRetrieval() { let (sut, store) = makeSUT() - sut.load() + sut.load() { _ in } XCTAssertEqual(store.receivedMessages, [.retrieve]) } + func test_load_failsOnRetrievalError() { + let (sut, store) = makeSUT() + let retrievalError = anyNSError() + var receivedError: Error? + let exp = expectation(description: "Wait for load completion") + + sut.load { error in + receivedError = error + exp.fulfill() + } + + store.completeRetrieval(with: retrievalError) + wait(for: [exp], timeout: 1.0) + + XCTAssertEqual(receivedError as NSError?, retrievalError) + } + // MARK: - Helpers private func makeSUT( @@ -34,4 +51,6 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { trackForMemoryLeaks(sut, file: file, line: line) return (sut, store) } + + private func anyNSError() -> NSError { NSError(domain: "any error", code: 0) } } From eb710bf9b80c4338d725e8baee5091423f33f389 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 15:32:42 +0200 Subject: [PATCH 034/159] Replace load command completion to return a result type rather than an optional error --- .../EssentialFeed/Feed Cache/LocalFeedLoader.swift | 9 +++++++-- .../Feed Cache/LoadFeedFromCacheUseCaseTests.swift | 8 ++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 1d5d2c3..7cf5673 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -11,13 +11,18 @@ public final class LocalFeedLoader { private let currentDate: () -> Date public typealias SaveResult = Error? + public typealias LoadResult = LoadFeedResult public init(store: FeedStore, currentDate: @escaping () -> Date) { self.store = store self.currentDate = currentDate } - public func load(completion: @escaping (Error?) -> Void) { - store.retrieve(completion: completion) + public func load(completion: @escaping (LoadResult) -> Void) { + store.retrieve { error in + if let error = error { + completion(.failure(error)) + } + } } public func save(_ feed: [FeedImage], completion: @escaping (SaveResult) -> Void) { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 3c3a924..24d5fa5 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -27,8 +27,12 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { var receivedError: Error? let exp = expectation(description: "Wait for load completion") - sut.load { error in - receivedError = error + sut.load { result in + switch result { + case let .failure(error): + receivedError = error + default: XCTFail("Expected error, got \(result) instead") + } exp.fulfill() } From f8510e43156e0372a34c871866719e9c3eab3208 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 15:36:38 +0200 Subject: [PATCH 035/159] Load command delivers no images on empty cache --- .../Feed Cache/LocalFeedLoader.swift | 2 ++ .../Feed Cache/Helpers/FeedStoreSpy.swift | 4 ++++ .../LoadFeedFromCacheUseCaseTests.swift | 20 +++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 7cf5673..b30d4e5 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -21,6 +21,8 @@ public final class LocalFeedLoader { store.retrieve { error in if let error = error { completion(.failure(error)) + } else { + completion(.success([])) } } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift index 87e60ab..63f7429 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift @@ -57,4 +57,8 @@ class FeedStoreSpy: FeedStore { func completeRetrieval(with error: Error, at index: Int = 0) { retrievalCompletions[index](error) } + + func completeRetrievalWithEmptyCache(at index: Int = 0) { + retrievalCompletions[index](nil) + } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 24d5fa5..807758b 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -42,6 +42,26 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { XCTAssertEqual(receivedError as NSError?, retrievalError) } + func test_load_deliversNoImagesOnEmptyCache() { + let (sut, store) = makeSUT() + var receivedImages: [FeedImage]? + let exp = expectation(description: "Wait for load completion") + + sut.load { result in + exp.fulfill() + switch result { + case .success(let images): + receivedImages = images + default: XCTFail("Expected success, got \(result) instead") + } + } + + store.completeRetrievalWithEmptyCache() + wait(for: [exp], timeout: 1.0) + + XCTAssertEqual(receivedImages, []) + } + // MARK: - Helpers private func makeSUT( From c10236eb52f9d2b0efe0360648c70c8d719c9432 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 15:43:52 +0200 Subject: [PATCH 036/159] Extract duplicated test code into a shared helper method --- .../LoadFeedFromCacheUseCaseTests.swift | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 807758b..b6a8438 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -24,42 +24,18 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { func test_load_failsOnRetrievalError() { let (sut, store) = makeSUT() let retrievalError = anyNSError() - var receivedError: Error? - let exp = expectation(description: "Wait for load completion") - - sut.load { result in - switch result { - case let .failure(error): - receivedError = error - default: XCTFail("Expected error, got \(result) instead") - } - exp.fulfill() - } - - store.completeRetrieval(with: retrievalError) - wait(for: [exp], timeout: 1.0) - XCTAssertEqual(receivedError as NSError?, retrievalError) + expect(sut, toCompleteWith: .failure(retrievalError), when: { + store.completeRetrieval(with: retrievalError) + }) } func test_load_deliversNoImagesOnEmptyCache() { let (sut, store) = makeSUT() - var receivedImages: [FeedImage]? - let exp = expectation(description: "Wait for load completion") - - sut.load { result in - exp.fulfill() - switch result { - case .success(let images): - receivedImages = images - default: XCTFail("Expected success, got \(result) instead") - } - } - store.completeRetrievalWithEmptyCache() - wait(for: [exp], timeout: 1.0) - - XCTAssertEqual(receivedImages, []) + expect(sut, toCompleteWith: .success([]), when: { + store.completeRetrievalWithEmptyCache() + }) } // MARK: - Helpers @@ -76,5 +52,31 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { return (sut, store) } + private func expect( + _ sut: LocalFeedLoader, + toCompleteWith expectedResult: LocalFeedLoader.LoadResult, + when action: () -> Void, + file: StaticString = #file, + line: UInt = #line + ) { + let exp = expectation(description: "Wait for load completion") + + sut.load { receivedResult in + exp.fulfill() + switch (receivedResult, expectedResult) { + case let (.success(receivedImages), .success(expectedImages)): + XCTAssertEqual(receivedImages, expectedImages, file: file, line: line) + case let (.failure(receivedError as NSError), .failure(expectedError as NSError)): + XCTAssertEqual(receivedError, expectedError, file: file, line: line) + default: + XCTFail("Expected result \(expectedResult), got \(receivedResult) instead", file: file, line: line) + } + + } + + action() + wait(for: [exp], timeout: 1.0) + } + private func anyNSError() -> NSError { NSError(domain: "any error", code: 0) } } From fb65fc0c3b4103b921a61f869847916488d2ab2d Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 15:55:01 +0200 Subject: [PATCH 037/159] Load commnd delivers cached images on less than seven days old cache --- .../EssentialFeed/Feed Cache/FeedStore.swift | 8 +++- .../Feed Cache/LocalFeedLoader.swift | 22 ++++++++-- .../Feed Cache/Helpers/FeedStoreSpy.swift | 8 +++- .../LoadFeedFromCacheUseCaseTests.swift | 41 +++++++++++++++++++ 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift index 1425707..8810996 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -7,10 +7,16 @@ import Foundation +public enum RetrieveCachedFeedResult { + case empty + case found(feed: [LocalFeedImage], timestamp: Date) + case failure(Error) +} + public protocol FeedStore { typealias DeletionCompletion = (Error?) -> Void typealias InsertionCompletion = (Error?) -> Void - typealias RetrievalCompletion = (Error?) -> Void + typealias RetrievalCompletion = (RetrieveCachedFeedResult) -> Void func deleteCachedFeed(completion: @escaping DeletionCompletion) func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index b30d4e5..f3a8691 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -18,11 +18,14 @@ public final class LocalFeedLoader { } public func load(completion: @escaping (LoadResult) -> Void) { - store.retrieve { error in - if let error = error { + store.retrieve { result in + switch result { + case let .failure(error): completion(.failure(error)) - } else { + case .empty: completion(.success([])) + case let .found(feed, _): + completion(.success(feed.toModels())) } } } @@ -58,3 +61,16 @@ private extension Array where Element == FeedImage { } } } + +private extension Array where Element == LocalFeedImage { + func toModels() -> [FeedImage] { + return map { + FeedImage( + id: $0.id, + description: $0.description, + location: $0.location, + url: $0.url + ) + } + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift index 63f7429..d142bce 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift @@ -55,10 +55,14 @@ class FeedStoreSpy: FeedStore { } func completeRetrieval(with error: Error, at index: Int = 0) { - retrievalCompletions[index](error) + retrievalCompletions[index](.failure(error)) } func completeRetrievalWithEmptyCache(at index: Int = 0) { - retrievalCompletions[index](nil) + retrievalCompletions[index](.empty) + } + + func completeRetrieval(with feed: [LocalFeedImage], timestamp: Date, at index: Int = 0) { + retrievalCompletions[index](.found(feed: feed, timestamp: timestamp)) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index b6a8438..957b384 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -38,6 +38,17 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { }) } + func test_load_deliversCachedImagesOnLessThanSevenDaysOldCache() { + let feed = uniqueImageFeed() + let fixedCurrentDate = Date() + let lessThanSevenDaysOldTimestamp = fixedCurrentDate.adding(days: -7).adding(seconds: -1) + + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + expect(sut, toCompleteWith: .success(feed.models), when: { + store.completeRetrieval(with: feed.local, timestamp: lessThanSevenDaysOldTimestamp) + }) + } + // MARK: - Helpers private func makeSUT( @@ -79,4 +90,34 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { } private func anyNSError() -> NSError { NSError(domain: "any error", code: 0) } + private func anyURL() -> URL { + URL(string: "http://any-url.com")! + } + + func uniqueImageFeed() -> (models: [FeedImage], local: [LocalFeedImage]) { + let models = [uniqueImage(), uniqueImage()] + let local = models.map { + LocalFeedImage( + id: $0.id, + description: $0.description, + location: $0.location, + url: $0.url + ) + } + return (models, local) + } + + func uniqueImage() -> FeedImage { + return FeedImage(id: UUID(), description: "any", location: "any", url: anyURL()) + } +} + +private extension Date { + func adding(days: Int) -> Date { + return Calendar(identifier: .gregorian).date(byAdding: .day, value: days, to: self)! + } + + func adding(seconds: TimeInterval) -> Date { + return self + seconds + } } From 871880d5c17b31dc3c2fc04b62ab8be3b4a2c057 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 16:08:14 +0200 Subject: [PATCH 038/159] Load command delivers no images on seven days old cache --- .../Feed Cache/LocalFeedLoader.swift | 17 +++++++++++++---- .../LoadFeedFromCacheUseCaseTests.swift | 17 ++++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index f3a8691..c2f1994 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -18,18 +18,27 @@ public final class LocalFeedLoader { } public func load(completion: @escaping (LoadResult) -> Void) { - store.retrieve { result in + store.retrieve { [unowned self] result in switch result { case let .failure(error): completion(.failure(error)) - case .empty: - completion(.success([])) - case let .found(feed, _): + + case let .found(feed, timestamp) where self.validate(timestamp): completion(.success(feed.toModels())) + case .found, .empty: + completion(.success([])) } } } + private func validate(_ timestamp: Date) -> Bool { + let calendar = Calendar(identifier: .gregorian) + guard let maxCacheAge = calendar.date(byAdding: .day, value: 7, to: timestamp) else { + return false + } + return currentDate() < maxCacheAge + } + public func save(_ feed: [FeedImage], completion: @escaping (SaveResult) -> Void) { store.deleteCachedFeed { [weak self] error in guard let self else { return } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 957b384..45c452c 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -41,10 +41,25 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { func test_load_deliversCachedImagesOnLessThanSevenDaysOldCache() { let feed = uniqueImageFeed() let fixedCurrentDate = Date() - let lessThanSevenDaysOldTimestamp = fixedCurrentDate.adding(days: -7).adding(seconds: -1) + let lessThanSevenDaysOldTimestamp = fixedCurrentDate + .adding(days: -7) + .adding(seconds: 1) let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) expect(sut, toCompleteWith: .success(feed.models), when: { + store.completeRetrieval( + with: feed.local, + timestamp: lessThanSevenDaysOldTimestamp) + }) + } + + func test_load_deliversNoImagesOnSevenDaysOldCache() { + let feed = uniqueImageFeed() + let fixedCurrentDate = Date() + let lessThanSevenDaysOldTimestamp = fixedCurrentDate.adding(days: -7) + + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + expect(sut, toCompleteWith: .success([]), when: { store.completeRetrieval(with: feed.local, timestamp: lessThanSevenDaysOldTimestamp) }) } From 88e9a17ccd3d3905cb687a059b468254681c6d08 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 16:15:07 +0200 Subject: [PATCH 039/159] Extract local members into properties --- EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index c2f1994..483045e 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -9,6 +9,7 @@ import Foundation public final class LocalFeedLoader { private let store: FeedStore private let currentDate: () -> Date + private let calendar = Calendar(identifier: .gregorian) public typealias SaveResult = Error? public typealias LoadResult = LoadFeedResult @@ -31,9 +32,9 @@ public final class LocalFeedLoader { } } + private var maxCacheAgeInDays: Int { return 7 } private func validate(_ timestamp: Date) -> Bool { - let calendar = Calendar(identifier: .gregorian) - guard let maxCacheAge = calendar.date(byAdding: .day, value: 7, to: timestamp) else { + guard let maxCacheAge = calendar.date(byAdding: .day, value: maxCacheAgeInDays, to: timestamp) else { return false } return currentDate() < maxCacheAge From 7b70f80de0ec868ba3eb4476a20b8372564cac5d Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Thu, 3 Apr 2025 16:16:26 +0200 Subject: [PATCH 040/159] Load command delivers no images on more than seven days old cache --- .../Feed Cache/LoadFeedFromCacheUseCaseTests.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 45c452c..0bf8f23 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -64,6 +64,19 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { }) } + func test_load_deliversNoImagesOnMoreThanSevenDaysOldCache() { + let feed = uniqueImageFeed() + let fixedCurrentDate = Date() + let moreThanSevenDaysOldTimestamp = fixedCurrentDate + .adding(days: -7) + .adding(seconds: -1) + + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + expect(sut, toCompleteWith: .success([]), when: { + store.completeRetrieval(with: feed.local, timestamp: moreThanSevenDaysOldTimestamp) + }) + } + // MARK: - Helpers private func makeSUT( From 7a60ea93db8d32c26fafd0e93f77986acc4f14fb Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 10:36:37 +0200 Subject: [PATCH 041/159] Deletes cache on retrieval error --- .../EssentialFeed/Feed Cache/LocalFeedLoader.swift | 1 + .../Feed Cache/LoadFeedFromCacheUseCaseTests.swift | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 483045e..dbe3275 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -22,6 +22,7 @@ public final class LocalFeedLoader { store.retrieve { [unowned self] result in switch result { case let .failure(error): + self.store.deleteCachedFeed { _ in } completion(.failure(error)) case let .found(feed, timestamp) where self.validate(timestamp): diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 0bf8f23..eccbf4c 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -77,6 +77,13 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { }) } + func test_load_deletesCacheOnRetrievalError() { + let (sut, store) = makeSUT() + sut.load { _ in } + store.completeRetrieval(with: anyNSError()) + XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) + } + // MARK: - Helpers private func makeSUT( From 03e54100eeeb0df78d1cd6ce9bc5178c5a6fb930 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 10:37:38 +0200 Subject: [PATCH 042/159] Load command does not delete cache when cache is already empty --- .../Feed Cache/LoadFeedFromCacheUseCaseTests.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index eccbf4c..ab069b7 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -84,6 +84,13 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) } + func test_load_doesNotDeleteCacheOnEmptyCache() { + let (sut, store) = makeSUT() + sut.load { _ in } + store.completeRetrievalWithEmptyCache() + XCTAssertEqual(store.receivedMessages, [.retrieve]) + } + // MARK: - Helpers private func makeSUT( From 61701777de6094f5efdbe547051e256c28ceff24 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 10:41:00 +0200 Subject: [PATCH 043/159] Load command does not delete less than seven days old cache --- .../LoadFeedFromCacheUseCaseTests.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index ab069b7..6eab50d 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -91,6 +91,21 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve]) } + func test_load_doesNotDeleteCacheOnLessThanSevenDaysOldCache() { + let fixedCurrentDate = Date() + let lessThanSevenDaysOldTimestamp = fixedCurrentDate + .adding(days: -7) + .adding(seconds: 1) + + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + sut.load { _ in } + store.completeRetrieval( + with: uniqueImageFeed().local, + timestamp: lessThanSevenDaysOldTimestamp + ) + XCTAssertEqual(store.receivedMessages, [.retrieve]) + } + // MARK: - Helpers private func makeSUT( From d01a9bcfb8fff244b44de105c02822cfbe423842 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 10:44:24 +0200 Subject: [PATCH 044/159] Load command deletes seven days old cache upon retrieval --- .../EssentialFeed/Feed Cache/LocalFeedLoader.swift | 5 ++++- .../Feed Cache/LoadFeedFromCacheUseCaseTests.swift | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index dbe3275..fb6ac74 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -27,7 +27,10 @@ public final class LocalFeedLoader { case let .found(feed, timestamp) where self.validate(timestamp): completion(.success(feed.toModels())) - case .found, .empty: + case .found: + self.store.deleteCachedFeed { _ in } + completion(.success([])) + case .empty: completion(.success([])) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 6eab50d..1d1c1d1 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -106,6 +106,16 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve]) } + func test_load_deletesCacheOnSevenDaysOldCache() { + let fixedCurrentDate = Date() + let sevenDaysOldTimestamp = fixedCurrentDate.adding(days: -7) + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + + sut.load { _ in } + store.completeRetrieval(with: uniqueImageFeed().local, timestamp: sevenDaysOldTimestamp) + XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) + } + // MARK: - Helpers private func makeSUT( From 6bfe842b008f5ac412c49c054bea67c44d58140e Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 10:46:17 +0200 Subject: [PATCH 045/159] Load command deletes more than seven days old cache upon retrieval --- .../LoadFeedFromCacheUseCaseTests.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 1d1c1d1..5d7e338 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -116,6 +116,23 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) } + func test_load_deletesCacheOnMoreThanSevenDaysOldCache() { + let fixedCurrentDate = Date() + let moreThanSevenDaysOldCacheTimestamp = fixedCurrentDate + .adding(days: -7) + .adding(seconds: -1) + + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + + sut.load { _ in } + store.completeRetrieval( + with: uniqueImageFeed().local, + timestamp: moreThanSevenDaysOldCacheTimestamp + ) + + XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) + } + // MARK: - Helpers private func makeSUT( From a301ea77807cb217464e8f48f23c29a57b7f9679 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 10:58:35 +0200 Subject: [PATCH 046/159] Load command does not delive a load result after the instance has been deallocated to prevent unexpected behaviors in clients code --- .../Feed Cache/LocalFeedLoader.swift | 3 ++- .../LoadFeedFromCacheUseCaseTests.swift | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index fb6ac74..015376d 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -19,7 +19,8 @@ public final class LocalFeedLoader { } public func load(completion: @escaping (LoadResult) -> Void) { - store.retrieve { [unowned self] result in + store.retrieve { [weak self] result in + guard let self else { return } switch result { case let .failure(error): self.store.deleteCachedFeed { _ in } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 5d7e338..be4ee2b 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -133,6 +133,21 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) } + func test_load_doesNotDeliverResultAfterSUTInstanceHasBeenDeallocated() { + let store = FeedStoreSpy() + var sut: LocalFeedLoader? = LocalFeedLoader( + store: store, + currentDate: Date.init + ) + + var receivedResults = [LocalFeedLoader.LoadResult]() + sut?.load { receivedResults.append($0) } + + sut = nil + store.completeRetrievalWithEmptyCache() + XCTAssertTrue(receivedResults.isEmpty) + } + // MARK: - Helpers private func makeSUT( From 63125315e11e7b04c40042dd006cd78418540170 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 11:06:36 +0200 Subject: [PATCH 047/159] LocalFeedLoader does not message the store upon creation before validating the cached feed --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 +++ .../ValidateFeedCacheUseCaseTests.swift | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 4285979..5a01095 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 080EDEFB21B6DA7E00813479 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 080EDF0C21B6DAE800813479 /* FeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0B21B6DAE800813479 /* FeedImage.swift */; }; 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0D21B6DCB600813479 /* FeedLoader.swift */; }; + 407F4FA52D9FD7F90070F56E /* ValidateFeedCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */; }; 40B002492CF9E9DB0058D3E0 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; @@ -43,6 +44,7 @@ 080EDF0121B6DA7E00813479 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 080EDF0B21B6DAE800813479 /* FeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImage.swift; sourceTree = ""; }; 080EDF0D21B6DCB600813479 /* FeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoader.swift; sourceTree = ""; }; + 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateFeedCacheUseCaseTests.swift; sourceTree = ""; }; 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; @@ -164,6 +166,7 @@ 40B9754C2D9EC147009652B5 /* Helpers */, 40B9754A2D9EC05C009652B5 /* LoadFeedFromCacheUseCaseTests.swift */, 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */, + 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */, ); path = "Feed Cache"; sourceTree = ""; @@ -347,6 +350,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 407F4FA52D9FD7F90070F56E /* ValidateFeedCacheUseCaseTests.swift in Sources */, 40B9754E2D9EC15A009652B5 /* FeedStoreSpy.swift in Sources */, 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */, 40B9754B2D9EC061009652B5 /* LoadFeedFromCacheUseCaseTests.swift in Sources */, diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift new file mode 100644 index 0000000..9e4f951 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift @@ -0,0 +1,30 @@ +// +// ValidateFeedCacheUseCaseTests.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 4/4/25. +// + +import XCTest +import EssentialFeed + +class ValidateFeedCacheUseCaseTests: XCTestCase { + + func test_doesNotMessageStoreUponCreation() { + let (_, store) = makeSUT() + XCTAssertEqual(store.receivedMessages, []) + } + + // MARK: - Helpers + private func makeSUT( + currentDate: @escaping () -> Date = Date.init, + file: StaticString = #file, + line: UInt = #line + ) -> (sut: LocalFeedLoader, store: FeedStoreSpy) { + let store = FeedStoreSpy() + let sut = LocalFeedLoader(store: store, currentDate: currentDate) + trackForMemoryLeaks(store, file: file, line: line) + trackForMemoryLeaks(sut, file: file, line: line) + return (sut, store) + } +} From 4e19f751419e8925a19e6153144d27df2330647d Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 11:13:53 +0200 Subject: [PATCH 048/159] Extract cache deletion side-effect on retrieval error from the load method to the validate cache method --- .../EssentialFeed/Feed Cache/LocalFeedLoader.swift | 6 +++++- .../Feed Cache/LoadFeedFromCacheUseCaseTests.swift | 4 ++-- .../Feed Cache/ValidateFeedCacheUseCaseTests.swift | 10 ++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 015376d..3bde08c 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -23,7 +23,6 @@ public final class LocalFeedLoader { guard let self else { return } switch result { case let .failure(error): - self.store.deleteCachedFeed { _ in } completion(.failure(error)) case let .found(feed, timestamp) where self.validate(timestamp): @@ -37,6 +36,11 @@ public final class LocalFeedLoader { } } + public func validateCache() { + store.retrieve { _ in } + store.deleteCachedFeed { _ in } + } + private var maxCacheAgeInDays: Int { return 7 } private func validate(_ timestamp: Date) -> Bool { guard let maxCacheAge = calendar.date(byAdding: .day, value: maxCacheAgeInDays, to: timestamp) else { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index be4ee2b..ef93f86 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -77,11 +77,11 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { }) } - func test_load_deletesCacheOnRetrievalError() { + func test_load_hasNoSideEffectsOnRetrievalError() { let (sut, store) = makeSUT() sut.load { _ in } store.completeRetrieval(with: anyNSError()) - XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) + XCTAssertEqual(store.receivedMessages, [.retrieve]) } func test_load_doesNotDeleteCacheOnEmptyCache() { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift index 9e4f951..91c4e52 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift @@ -15,6 +15,14 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, []) } + func test_validateCache_deletesCacheOnRetrievalError() { + let (sut, store) = makeSUT() + sut.validateCache() + store.completeRetrieval(with: anyNSError()) + + XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) + } + // MARK: - Helpers private func makeSUT( currentDate: @escaping () -> Date = Date.init, @@ -27,4 +35,6 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { trackForMemoryLeaks(sut, file: file, line: line) return (sut, store) } + + private func anyNSError() -> NSError { NSError(domain: "any error", code: 0) } } From c161ac343cd9afb1cf9da7abe42137f1421ee9a2 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 11:15:56 +0200 Subject: [PATCH 049/159] Validate cache command does not delete cache when cache is already empty --- .../EssentialFeed/Feed Cache/LocalFeedLoader.swift | 9 +++++++-- .../Feed Cache/LoadFeedFromCacheUseCaseTests.swift | 2 +- .../Feed Cache/ValidateFeedCacheUseCaseTests.swift | 7 +++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 3bde08c..d5f3bf3 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -37,8 +37,13 @@ public final class LocalFeedLoader { } public func validateCache() { - store.retrieve { _ in } - store.deleteCachedFeed { _ in } + store.retrieve { [unowned self] result in + switch result { + case .failure: + self.store.deleteCachedFeed { _ in } + default: break + } + } } private var maxCacheAgeInDays: Int { return 7 } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index ef93f86..af749b0 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -84,7 +84,7 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve]) } - func test_load_doesNotDeleteCacheOnEmptyCache() { + func test_load_hasNoSideEffectsOnEmptyCache() { let (sut, store) = makeSUT() sut.load { _ in } store.completeRetrievalWithEmptyCache() diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift index 91c4e52..ac75bc4 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift @@ -23,6 +23,13 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) } + func test_validateCache_doesNotDeleteCacheOnEmptyCache() { + let (sut, store) = makeSUT() + sut.validateCache() + store.completeRetrievalWithEmptyCache() + XCTAssertEqual(store.receivedMessages, [.retrieve]) + } + // MARK: - Helpers private func makeSUT( currentDate: @escaping () -> Date = Date.init, From 4bb811352cacc9c6a1394f02d06dc77fdc70fa61 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 11:18:20 +0200 Subject: [PATCH 050/159] Validate cache command does not delete less than seven days old cache --- .../LoadFeedFromCacheUseCaseTests.swift | 2 +- .../ValidateFeedCacheUseCaseTests.swift | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index af749b0..27bee4b 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -91,7 +91,7 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve]) } - func test_load_doesNotDeleteCacheOnLessThanSevenDaysOldCache() { + func test_load_hasNoSideEffectsOnLessThanSevenDaysOldCache() { let fixedCurrentDate = Date() let lessThanSevenDaysOldTimestamp = fixedCurrentDate .adding(days: -7) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift index ac75bc4..bbba8b9 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift @@ -30,6 +30,21 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve]) } + func test_validateCache_doesNotDeleteCacheOnLessThanSevenDaysOldCache() { + let fixedCurrentDate = Date() + let lessThanSevenDaysOldTimestamp = fixedCurrentDate + .adding(days: -7) + .adding(seconds: 1) + + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + sut.validateCache() + store.completeRetrieval( + with: uniqueImageFeed().local, + timestamp: lessThanSevenDaysOldTimestamp + ) + XCTAssertEqual(store.receivedMessages, [.retrieve]) + } + // MARK: - Helpers private func makeSUT( currentDate: @escaping () -> Date = Date.init, @@ -44,4 +59,35 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { } private func anyNSError() -> NSError { NSError(domain: "any error", code: 0) } + + func uniqueImageFeed() -> (models: [FeedImage], local: [LocalFeedImage]) { + let models = [uniqueImage(), uniqueImage()] + let local = models.map { + LocalFeedImage( + id: $0.id, + description: $0.description, + location: $0.location, + url: $0.url + ) + } + return (models, local) + } + + func uniqueImage() -> FeedImage { + return FeedImage(id: UUID(), description: "any", location: "any", url: anyURL()) + } + + private func anyURL() -> URL { + URL(string: "http://any-url.com")! + } +} + +private extension Date { + func adding(days: Int) -> Date { + return Calendar(identifier: .gregorian).date(byAdding: .day, value: days, to: self)! + } + + func adding(seconds: TimeInterval) -> Date { + return self + seconds + } } From d48c2b3faf1d1f49d52267b5a8cd3542cf3eac83 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 11:25:13 +0200 Subject: [PATCH 051/159] Extract duplicated test helpers into a shared scope --- .../EssentialFeed.xcodeproj/project.pbxproj | 5 +++ .../Feed Api/URLSessionHTTPClientTests.swift | 5 --- .../Feed Cache/CacheFeedUseCaseTests.swift | 23 ------------ .../Helpers/FeedCacheTestHelpers.swift | 37 +++++++++++++++++++ .../LoadFeedFromCacheUseCaseTests.swift | 32 ---------------- .../ValidateFeedCacheUseCaseTests.swift | 35 +----------------- .../Helpers/SharedTestHelpers.swift | 15 ++++++++ 7 files changed, 58 insertions(+), 94 deletions(-) create mode 100644 EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift create mode 100644 EssentialFeed/EssentialFeedTests/Helpers/SharedTestHelpers.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 5a01095..defda19 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 080EDF0C21B6DAE800813479 /* FeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0B21B6DAE800813479 /* FeedImage.swift */; }; 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0D21B6DCB600813479 /* FeedLoader.swift */; }; 407F4FA52D9FD7F90070F56E /* ValidateFeedCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */; }; + 407F4FA72D9FDB810070F56E /* FeedCacheTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */; }; 40B002492CF9E9DB0058D3E0 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; @@ -45,6 +46,7 @@ 080EDF0B21B6DAE800813479 /* FeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImage.swift; sourceTree = ""; }; 080EDF0D21B6DCB600813479 /* FeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoader.swift; sourceTree = ""; }; 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateFeedCacheUseCaseTests.swift; sourceTree = ""; }; + 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCacheTestHelpers.swift; sourceTree = ""; }; 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; @@ -58,6 +60,7 @@ 40B975402D9E7CB2009652B5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + SharedTestHelpers.swift, "XCTestCase+MemoryLeakTrackingHelper.swift", ); target = 40B002442CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */; @@ -185,6 +188,7 @@ isa = PBXGroup; children = ( 40B9754D2D9EC15A009652B5 /* FeedStoreSpy.swift */, + 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */, ); path = Helpers; sourceTree = ""; @@ -354,6 +358,7 @@ 40B9754E2D9EC15A009652B5 /* FeedStoreSpy.swift in Sources */, 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */, 40B9754B2D9EC061009652B5 /* LoadFeedFromCacheUseCaseTests.swift in Sources */, + 407F4FA72D9FDB810070F56E /* FeedCacheTestHelpers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/EssentialFeed/EssentialFeedTests/Feed Api/URLSessionHTTPClientTests.swift b/EssentialFeed/EssentialFeedTests/Feed Api/URLSessionHTTPClientTests.swift index 4868054..6e36dba 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Api/URLSessionHTTPClientTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Api/URLSessionHTTPClientTests.swift @@ -169,15 +169,10 @@ class URLSessionHTTPClientTests: XCTestCase { } private func anyData() -> Data { Data("any data".utf8) } - private func anyNSError() -> NSError { NSError(domain: "any error", code: 0) } private func nonHTTPURLResponse() -> URLResponse { URLResponse() } private func anyHTTPURLResponse() -> HTTPURLResponse { HTTPURLResponse() } - private func anyURL() -> URL { - URL(string: "http://any-url.com")! - } - private class URLProtocolStub: URLProtocol { private static var stub: Stub? private static var requestObserver: ((URLRequest) -> Void)? diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index dab3309..1319e81 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -126,27 +126,4 @@ class CacheFeedUseCaseTests: XCTestCase { line: line ) } - - func uniqueImageFeed() -> (models: [FeedImage], local: [LocalFeedImage]) { - let models = [uniqueImage(), uniqueImage()] - let local = models.map { - LocalFeedImage( - id: $0.id, - description: $0.description, - location: $0.location, - url: $0.url - ) - } - return (models, local) - } - - func uniqueImage() -> FeedImage { - return FeedImage(id: UUID(), description: "any", location: "any", url: anyURL()) - } - - private func anyURL() -> URL { - URL(string: "http://any-url.com")! - } - - private func anyNSError() -> NSError { NSError(domain: "any error", code: 0) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift new file mode 100644 index 0000000..8ea825a --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift @@ -0,0 +1,37 @@ +// +// FeedCacheTestHelpers.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 4/4/25. +// + +import Foundation +import EssentialFeed + +func uniqueImageFeed() -> (models: [FeedImage], local: [LocalFeedImage]) { + let models = [uniqueImage(), uniqueImage()] + let local = models.map { + LocalFeedImage( + id: $0.id, + description: $0.description, + location: $0.location, + url: $0.url + ) + } + return (models, local) +} + +func uniqueImage() -> FeedImage { + return FeedImage(id: UUID(), description: "any", location: "any", url: anyURL()) +} + +extension Date { + func adding(days: Int) -> Date { + return Calendar(identifier: .gregorian).date(byAdding: .day, value: days, to: self)! + } + + func adding(seconds: TimeInterval) -> Date { + return self + seconds + } +} + diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 27bee4b..b193788 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -187,36 +187,4 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { action() wait(for: [exp], timeout: 1.0) } - - private func anyNSError() -> NSError { NSError(domain: "any error", code: 0) } - private func anyURL() -> URL { - URL(string: "http://any-url.com")! - } - - func uniqueImageFeed() -> (models: [FeedImage], local: [LocalFeedImage]) { - let models = [uniqueImage(), uniqueImage()] - let local = models.map { - LocalFeedImage( - id: $0.id, - description: $0.description, - location: $0.location, - url: $0.url - ) - } - return (models, local) - } - - func uniqueImage() -> FeedImage { - return FeedImage(id: UUID(), description: "any", location: "any", url: anyURL()) - } -} - -private extension Date { - func adding(days: Int) -> Date { - return Calendar(identifier: .gregorian).date(byAdding: .day, value: days, to: self)! - } - - func adding(seconds: TimeInterval) -> Date { - return self + seconds - } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift index bbba8b9..15d2d3b 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift @@ -9,7 +9,7 @@ import XCTest import EssentialFeed class ValidateFeedCacheUseCaseTests: XCTestCase { - + func test_doesNotMessageStoreUponCreation() { let (_, store) = makeSUT() XCTAssertEqual(store.receivedMessages, []) @@ -57,37 +57,4 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { trackForMemoryLeaks(sut, file: file, line: line) return (sut, store) } - - private func anyNSError() -> NSError { NSError(domain: "any error", code: 0) } - - func uniqueImageFeed() -> (models: [FeedImage], local: [LocalFeedImage]) { - let models = [uniqueImage(), uniqueImage()] - let local = models.map { - LocalFeedImage( - id: $0.id, - description: $0.description, - location: $0.location, - url: $0.url - ) - } - return (models, local) - } - - func uniqueImage() -> FeedImage { - return FeedImage(id: UUID(), description: "any", location: "any", url: anyURL()) - } - - private func anyURL() -> URL { - URL(string: "http://any-url.com")! - } -} - -private extension Date { - func adding(days: Int) -> Date { - return Calendar(identifier: .gregorian).date(byAdding: .day, value: days, to: self)! - } - - func adding(seconds: TimeInterval) -> Date { - return self + seconds - } } diff --git a/EssentialFeed/EssentialFeedTests/Helpers/SharedTestHelpers.swift b/EssentialFeed/EssentialFeedTests/Helpers/SharedTestHelpers.swift new file mode 100644 index 0000000..0044fad --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Helpers/SharedTestHelpers.swift @@ -0,0 +1,15 @@ +// +// SharedTestHelpers.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 4/4/25. +// + +import Foundation + +func anyNSError() -> NSError { NSError(domain: "any error", code: 0) +} + +func anyURL() -> URL { + URL(string: "http://any-url.com")! +} From 27573447b89af8c23a10109e1986c74a0e4eaac0 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 11:36:06 +0200 Subject: [PATCH 052/159] Extract cache deletion side-effects on expired cache from the load method to the validate cache method --- .../Feed Cache/LocalFeedLoader.swift | 5 ++-- .../LoadFeedFromCacheUseCaseTests.swift | 8 ++--- .../ValidateFeedCacheUseCaseTests.swift | 29 ++++++++++++++++++- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index d5f3bf3..37beac8 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -28,7 +28,6 @@ public final class LocalFeedLoader { case let .found(feed, timestamp) where self.validate(timestamp): completion(.success(feed.toModels())) case .found: - self.store.deleteCachedFeed { _ in } completion(.success([])) case .empty: completion(.success([])) @@ -39,9 +38,11 @@ public final class LocalFeedLoader { public func validateCache() { store.retrieve { [unowned self] result in switch result { + case let .found(_, timestamp) where !self.validate(timestamp): + self.store.deleteCachedFeed { _ in } case .failure: self.store.deleteCachedFeed { _ in } - default: break + case .empty, .found: break } } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index b193788..2723875 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -106,17 +106,17 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve]) } - func test_load_deletesCacheOnSevenDaysOldCache() { + func test_load_hasNoSideEffectsOnSevenDaysOldCache() { let fixedCurrentDate = Date() let sevenDaysOldTimestamp = fixedCurrentDate.adding(days: -7) let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) sut.load { _ in } store.completeRetrieval(with: uniqueImageFeed().local, timestamp: sevenDaysOldTimestamp) - XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) + XCTAssertEqual(store.receivedMessages, [.retrieve]) } - func test_load_deletesCacheOnMoreThanSevenDaysOldCache() { + func test_load_hasNoSideEffectsOnMoreThanSevenDaysOldCache() { let fixedCurrentDate = Date() let moreThanSevenDaysOldCacheTimestamp = fixedCurrentDate .adding(days: -7) @@ -130,7 +130,7 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { timestamp: moreThanSevenDaysOldCacheTimestamp ) - XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) + XCTAssertEqual(store.receivedMessages, [.retrieve]) } func test_load_doesNotDeliverResultAfterSUTInstanceHasBeenDeallocated() { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift index 15d2d3b..05cb014 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift @@ -30,7 +30,7 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve]) } - func test_validateCache_doesNotDeleteCacheOnLessThanSevenDaysOldCache() { + func test_validateCache_doesNotDeleteLessThanSevenDaysOldCache() { let fixedCurrentDate = Date() let lessThanSevenDaysOldTimestamp = fixedCurrentDate .adding(days: -7) @@ -45,6 +45,33 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve]) } + func test_validateCache_deletesSevenDaysOldCache() { + let fixedCurrentDate = Date() + let sevenDaysOldTimestamp = fixedCurrentDate.adding(days: -7) + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + + sut.validateCache() + store.completeRetrieval(with: uniqueImageFeed().local, timestamp: sevenDaysOldTimestamp) + XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) + } + + func test_validateCache_deletesMoreThanSevenDaysOldCache() { + let fixedCurrentDate = Date() + let moreThanSevenDaysOldCacheTimestamp = fixedCurrentDate + .adding(days: -7) + .adding(seconds: -1) + + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + + sut.validateCache() + store.completeRetrieval( + with: uniqueImageFeed().local, + timestamp: moreThanSevenDaysOldCacheTimestamp + ) + + XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) + } + // MARK: - Helpers private func makeSUT( currentDate: @escaping () -> Date = Date.init, From 637a60330b75f4242989013fb36bdba8be5fc8b9 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 11:38:41 +0200 Subject: [PATCH 053/159] Validate cache command does not delete cache after instance has been deallocated --- .../EssentialFeed/Feed Cache/LocalFeedLoader.swift | 3 ++- .../Feed Cache/ValidateFeedCacheUseCaseTests.swift | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 37beac8..e81cc52 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -36,7 +36,8 @@ public final class LocalFeedLoader { } public func validateCache() { - store.retrieve { [unowned self] result in + store.retrieve { [weak self] result in + guard let self else { return } switch result { case let .found(_, timestamp) where !self.validate(timestamp): self.store.deleteCachedFeed { _ in } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift index 05cb014..2ccf77d 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift @@ -72,6 +72,17 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) } + func test_validateCache_doesNotDeleteInvalidCacheAfterSUTInstanceHasBeenDeallocated() { + let store = FeedStoreSpy() + var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) + + sut?.validateCache() + sut = nil + store.completeRetrieval(with: anyNSError()) + + XCTAssertEqual(store.receivedMessages, [.retrieve]) + } + // MARK: - Helpers private func makeSUT( currentDate: @escaping () -> Date = Date.init, From f4b56b0562a3f80482a847577a8def27c19ce301 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 11:39:30 +0200 Subject: [PATCH 054/159] Group cases to remove duplication --- EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index e81cc52..402d1c8 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -27,9 +27,7 @@ public final class LocalFeedLoader { case let .found(feed, timestamp) where self.validate(timestamp): completion(.success(feed.toModels())) - case .found: - completion(.success([])) - case .empty: + case .found, .empty: completion(.success([])) } } From 7709baf4d9a5bfb65332d07563a85f1c8b664bd9 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 11:41:47 +0200 Subject: [PATCH 055/159] Segment functionnalities into extensions --- .../Feed Cache/LocalFeedLoader.swift | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 402d1c8..fa85663 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -18,6 +18,16 @@ public final class LocalFeedLoader { self.currentDate = currentDate } + private var maxCacheAgeInDays: Int { return 7 } + private func validate(_ timestamp: Date) -> Bool { + guard let maxCacheAge = calendar.date(byAdding: .day, value: maxCacheAgeInDays, to: timestamp) else { + return false + } + return currentDate() < maxCacheAge + } +} + +extension LocalFeedLoader { public func load(completion: @escaping (LoadResult) -> Void) { store.retrieve { [weak self] result in guard let self else { return } @@ -32,7 +42,9 @@ public final class LocalFeedLoader { } } } - +} + +extension LocalFeedLoader { public func validateCache() { store.retrieve { [weak self] result in guard let self else { return } @@ -45,15 +57,9 @@ public final class LocalFeedLoader { } } } - - private var maxCacheAgeInDays: Int { return 7 } - private func validate(_ timestamp: Date) -> Bool { - guard let maxCacheAge = calendar.date(byAdding: .day, value: maxCacheAgeInDays, to: timestamp) else { - return false - } - return currentDate() < maxCacheAge - } - +} + +extension LocalFeedLoader { public func save(_ feed: [FeedImage], completion: @escaping (SaveResult) -> Void) { store.deleteCachedFeed { [weak self] error in guard let self else { return } From c520a63105c947c00782db573f9f2360b5ed12ba Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 11:42:14 +0200 Subject: [PATCH 056/159] Make LocalFeedLoader conform to FeedLoader protocol --- EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index fa85663..66b2374 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -6,7 +6,7 @@ // import Foundation -public final class LocalFeedLoader { +public final class LocalFeedLoader: FeedLoader { private let store: FeedStore private let currentDate: () -> Date private let calendar = Calendar(identifier: .gregorian) From 08aea38b54971ba3a677108a21119bc01a5ef187 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 11:42:55 +0200 Subject: [PATCH 057/159] Move typealiases to pertinent segments --- EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 66b2374..3b8725b 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -11,8 +11,6 @@ public final class LocalFeedLoader: FeedLoader { private let currentDate: () -> Date private let calendar = Calendar(identifier: .gregorian) - public typealias SaveResult = Error? - public typealias LoadResult = LoadFeedResult public init(store: FeedStore, currentDate: @escaping () -> Date) { self.store = store self.currentDate = currentDate @@ -28,6 +26,7 @@ public final class LocalFeedLoader: FeedLoader { } extension LocalFeedLoader { + public typealias LoadResult = LoadFeedResult public func load(completion: @escaping (LoadResult) -> Void) { store.retrieve { [weak self] result in guard let self else { return } @@ -60,6 +59,7 @@ extension LocalFeedLoader { } extension LocalFeedLoader { + public typealias SaveResult = Error? public func save(_ feed: [FeedImage], completion: @escaping (SaveResult) -> Void) { store.deleteCachedFeed { [weak self] error in guard let self else { return } From 1eb6bc6091f2170e3851f9e2d1482477a6eacaf6 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 12:17:56 +0200 Subject: [PATCH 058/159] Extract cache validation policy into the new `FeedCachePolicy` type --- .../Feed Cache/LocalFeedLoader.swift | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 3b8725b..f6fade9 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -6,24 +6,33 @@ // import Foundation -public final class LocalFeedLoader: FeedLoader { - private let store: FeedStore +private final class FeedCachePolicy { private let currentDate: () -> Date private let calendar = Calendar(identifier: .gregorian) - public init(store: FeedStore, currentDate: @escaping () -> Date) { - self.store = store + init(currentDate: @escaping () -> Date) { self.currentDate = currentDate } private var maxCacheAgeInDays: Int { return 7 } - private func validate(_ timestamp: Date) -> Bool { + + func validate(_ timestamp: Date) -> Bool { guard let maxCacheAge = calendar.date(byAdding: .day, value: maxCacheAgeInDays, to: timestamp) else { return false } return currentDate() < maxCacheAge } } +public final class LocalFeedLoader: FeedLoader { + private let store: FeedStore + private let currentDate: () -> Date + private let cachePolicy: FeedCachePolicy + public init(store: FeedStore, currentDate: @escaping () -> Date) { + self.store = store + self.currentDate = currentDate + self.cachePolicy = FeedCachePolicy(currentDate: currentDate) + } +} extension LocalFeedLoader { public typealias LoadResult = LoadFeedResult @@ -34,7 +43,7 @@ extension LocalFeedLoader { case let .failure(error): completion(.failure(error)) - case let .found(feed, timestamp) where self.validate(timestamp): + case let .found(feed, timestamp) where self.cachePolicy.validate(timestamp): completion(.success(feed.toModels())) case .found, .empty: completion(.success([])) @@ -48,7 +57,7 @@ extension LocalFeedLoader { store.retrieve { [weak self] result in guard let self else { return } switch result { - case let .found(_, timestamp) where !self.validate(timestamp): + case let .found(_, timestamp) where !self.cachePolicy.validate(timestamp): self.store.deleteCachedFeed { _ in } case .failure: self.store.deleteCachedFeed { _ in } From dabbcd657759660561743de9206fbc4dfdf89a9f Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 12:20:57 +0200 Subject: [PATCH 059/159] Make the feed cache policy a pure type with no side effects (deterministic) --- .../Feed Cache/LocalFeedLoader.swift | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index f6fade9..b27801a 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -7,30 +7,24 @@ import Foundation private final class FeedCachePolicy { - private let currentDate: () -> Date private let calendar = Calendar(identifier: .gregorian) - init(currentDate: @escaping () -> Date) { - self.currentDate = currentDate - } - private var maxCacheAgeInDays: Int { return 7 } - func validate(_ timestamp: Date) -> Bool { + func validate(_ timestamp: Date, against date: Date) -> Bool { guard let maxCacheAge = calendar.date(byAdding: .day, value: maxCacheAgeInDays, to: timestamp) else { return false } - return currentDate() < maxCacheAge + return date < maxCacheAge } } public final class LocalFeedLoader: FeedLoader { private let store: FeedStore private let currentDate: () -> Date - private let cachePolicy: FeedCachePolicy + private let cachePolicy = FeedCachePolicy() public init(store: FeedStore, currentDate: @escaping () -> Date) { self.store = store self.currentDate = currentDate - self.cachePolicy = FeedCachePolicy(currentDate: currentDate) } } @@ -43,7 +37,7 @@ extension LocalFeedLoader { case let .failure(error): completion(.failure(error)) - case let .found(feed, timestamp) where self.cachePolicy.validate(timestamp): + case let .found(feed, timestamp) where self.cachePolicy.validate(timestamp, against: currentDate()): completion(.success(feed.toModels())) case .found, .empty: completion(.success([])) @@ -57,7 +51,7 @@ extension LocalFeedLoader { store.retrieve { [weak self] result in guard let self else { return } switch result { - case let .found(_, timestamp) where !self.cachePolicy.validate(timestamp): + case let .found(_, timestamp) where !self.cachePolicy.validate(timestamp, against: currentDate()): self.store.deleteCachedFeed { _ in } case .failure: self.store.deleteCachedFeed { _ in } From c244c0b3516869937fb05331322bb1c662187c7e Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 12:28:08 +0200 Subject: [PATCH 060/159] Make feed cache policy static since it doesn't keep any state --- .../EssentialFeed/Feed Cache/LocalFeedLoader.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index b27801a..a622328 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -7,21 +7,22 @@ import Foundation private final class FeedCachePolicy { - private let calendar = Calendar(identifier: .gregorian) + private static let calendar = Calendar(identifier: .gregorian) + private static var maxCacheAgeInDays: Int { return 7 } - private var maxCacheAgeInDays: Int { return 7 } + private init() {} - func validate(_ timestamp: Date, against date: Date) -> Bool { + static func validate(_ timestamp: Date, against date: Date) -> Bool { guard let maxCacheAge = calendar.date(byAdding: .day, value: maxCacheAgeInDays, to: timestamp) else { return false } return date < maxCacheAge } } + public final class LocalFeedLoader: FeedLoader { private let store: FeedStore private let currentDate: () -> Date - private let cachePolicy = FeedCachePolicy() public init(store: FeedStore, currentDate: @escaping () -> Date) { self.store = store self.currentDate = currentDate @@ -37,7 +38,7 @@ extension LocalFeedLoader { case let .failure(error): completion(.failure(error)) - case let .found(feed, timestamp) where self.cachePolicy.validate(timestamp, against: currentDate()): + case let .found(feed, timestamp) where FeedCachePolicy.validate(timestamp, against: currentDate()): completion(.success(feed.toModels())) case .found, .empty: completion(.success([])) @@ -51,7 +52,7 @@ extension LocalFeedLoader { store.retrieve { [weak self] result in guard let self else { return } switch result { - case let .found(_, timestamp) where !self.cachePolicy.validate(timestamp, against: currentDate()): + case let .found(_, timestamp) where !FeedCachePolicy.validate(timestamp, against: currentDate()): self.store.deleteCachedFeed { _ in } case .failure: self.store.deleteCachedFeed { _ in } From 453e1d14f32e23d2e683956688b2c2c5100dd824 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 12:30:41 +0200 Subject: [PATCH 061/159] Move FeedCachePolicy to its own file --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 ++++ .../Feed Cache/FeedCachePolicy.swift | 20 +++++++++++++++++++ .../Feed Cache/LocalFeedLoader.swift | 14 +------------ 3 files changed, 25 insertions(+), 13 deletions(-) create mode 100644 EssentialFeed/EssentialFeed/Feed Cache/FeedCachePolicy.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index defda19..5bfbbb6 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0D21B6DCB600813479 /* FeedLoader.swift */; }; 407F4FA52D9FD7F90070F56E /* ValidateFeedCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */; }; 407F4FA72D9FDB810070F56E /* FeedCacheTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */; }; + 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */; }; 40B002492CF9E9DB0058D3E0 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; @@ -47,6 +48,7 @@ 080EDF0D21B6DCB600813479 /* FeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoader.swift; sourceTree = ""; }; 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateFeedCacheUseCaseTests.swift; sourceTree = ""; }; 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCacheTestHelpers.swift; sourceTree = ""; }; + 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCachePolicy.swift; sourceTree = ""; }; 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; @@ -178,6 +180,7 @@ isa = PBXGroup; children = ( 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */, + 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */, 40B975442D9EA01D009652B5 /* FeedStore.swift */, 40B975482D9EA7A2009652B5 /* LocalFeedImage.swift */, ); @@ -345,6 +348,7 @@ 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */, 080EDF0C21B6DAE800813479 /* FeedImage.swift in Sources */, 40B975492D9EA7A2009652B5 /* LocalFeedImage.swift in Sources */, + 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */, 40B975452D9EA01D009652B5 /* FeedStore.swift in Sources */, 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */, ); diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedCachePolicy.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedCachePolicy.swift new file mode 100644 index 0000000..4b5450e --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedCachePolicy.swift @@ -0,0 +1,20 @@ +// +// FeedCachePolicy.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 4/4/25. +// + +import Foundation + +internal final class FeedCachePolicy { + private static let calendar = Calendar(identifier: .gregorian) + private static var maxCacheAgeInDays: Int { return 7 } + private init() {} + static func validate(_ timestamp: Date, against date: Date) -> Bool { + guard let maxCacheAge = calendar.date(byAdding: .day, value: maxCacheAgeInDays, to: timestamp) else { + return false + } + return date < maxCacheAge + } +} diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index a622328..191acf1 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -6,19 +6,7 @@ // import Foundation -private final class FeedCachePolicy { - private static let calendar = Calendar(identifier: .gregorian) - private static var maxCacheAgeInDays: Int { return 7 } - - private init() {} - - static func validate(_ timestamp: Date, against date: Date) -> Bool { - guard let maxCacheAge = calendar.date(byAdding: .day, value: maxCacheAgeInDays, to: timestamp) else { - return false - } - return date < maxCacheAge - } -} + public final class LocalFeedLoader: FeedLoader { private let store: FeedStore From d1f479c4a55f7249c5cb6308625d7d0fbad7cd8d Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 12:38:37 +0200 Subject: [PATCH 062/159] Hide cache expiration details from tests with a new DSL method to protect tests from breaking changes --- .../Helpers/FeedCacheTestHelpers.swift | 6 ++- .../LoadFeedFromCacheUseCaseTests.swift | 44 +++++++++---------- .../ValidateFeedCacheUseCaseTests.swift | 22 +++++----- 3 files changed, 38 insertions(+), 34 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift index 8ea825a..18c3ce8 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift @@ -26,7 +26,11 @@ func uniqueImage() -> FeedImage { } extension Date { - func adding(days: Int) -> Date { + func minusFeedCacheMaxAge() -> Date { + return adding(days: -7) + } + + private func adding(days: Int) -> Date { return Calendar(identifier: .gregorian).date(byAdding: .day, value: days, to: self)! } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift index 2723875..43ce6af 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -38,42 +38,42 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { }) } - func test_load_deliversCachedImagesOnLessThanSevenDaysOldCache() { + func test_load_deliversCachedImagesOnNonExpiredCache() { let feed = uniqueImageFeed() let fixedCurrentDate = Date() - let lessThanSevenDaysOldTimestamp = fixedCurrentDate - .adding(days: -7) + let nonExpiredTimestamp = fixedCurrentDate + .minusFeedCacheMaxAge() .adding(seconds: 1) let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) expect(sut, toCompleteWith: .success(feed.models), when: { store.completeRetrieval( with: feed.local, - timestamp: lessThanSevenDaysOldTimestamp) + timestamp: nonExpiredTimestamp) }) } - func test_load_deliversNoImagesOnSevenDaysOldCache() { + func test_load_deliversNoImagesOnCacheExpiration() { let feed = uniqueImageFeed() let fixedCurrentDate = Date() - let lessThanSevenDaysOldTimestamp = fixedCurrentDate.adding(days: -7) + let expirationTimestamp = fixedCurrentDate.minusFeedCacheMaxAge() let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) expect(sut, toCompleteWith: .success([]), when: { - store.completeRetrieval(with: feed.local, timestamp: lessThanSevenDaysOldTimestamp) + store.completeRetrieval(with: feed.local, timestamp: expirationTimestamp) }) } - func test_load_deliversNoImagesOnMoreThanSevenDaysOldCache() { + func test_load_deliversNoImagesOnExpiredCache() { let feed = uniqueImageFeed() let fixedCurrentDate = Date() - let moreThanSevenDaysOldTimestamp = fixedCurrentDate - .adding(days: -7) + let expiredTimestamp = fixedCurrentDate + .minusFeedCacheMaxAge() .adding(seconds: -1) let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) expect(sut, toCompleteWith: .success([]), when: { - store.completeRetrieval(with: feed.local, timestamp: moreThanSevenDaysOldTimestamp) + store.completeRetrieval(with: feed.local, timestamp: expiredTimestamp) }) } @@ -91,35 +91,35 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve]) } - func test_load_hasNoSideEffectsOnLessThanSevenDaysOldCache() { + func test_load_hasNoSideEffectsOnNonExpiredCache() { let fixedCurrentDate = Date() - let lessThanSevenDaysOldTimestamp = fixedCurrentDate - .adding(days: -7) + let nonExpiredTimestamp = fixedCurrentDate + .minusFeedCacheMaxAge() .adding(seconds: 1) let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) sut.load { _ in } store.completeRetrieval( with: uniqueImageFeed().local, - timestamp: lessThanSevenDaysOldTimestamp + timestamp: nonExpiredTimestamp ) XCTAssertEqual(store.receivedMessages, [.retrieve]) } - func test_load_hasNoSideEffectsOnSevenDaysOldCache() { + func test_load_hasNoSideEffectsOnCacheExpiration() { let fixedCurrentDate = Date() - let sevenDaysOldTimestamp = fixedCurrentDate.adding(days: -7) + let expirationTimestamp = fixedCurrentDate.minusFeedCacheMaxAge() let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) sut.load { _ in } - store.completeRetrieval(with: uniqueImageFeed().local, timestamp: sevenDaysOldTimestamp) + store.completeRetrieval(with: uniqueImageFeed().local, timestamp: expirationTimestamp) XCTAssertEqual(store.receivedMessages, [.retrieve]) } - func test_load_hasNoSideEffectsOnMoreThanSevenDaysOldCache() { + func test_load_hasNoSideEffectsOnExpiredCache() { let fixedCurrentDate = Date() - let moreThanSevenDaysOldCacheTimestamp = fixedCurrentDate - .adding(days: -7) + let expiredTimestamp = fixedCurrentDate + .minusFeedCacheMaxAge() .adding(seconds: -1) let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) @@ -127,7 +127,7 @@ class LoadFeedFromCacheUseCaseTests: XCTestCase { sut.load { _ in } store.completeRetrieval( with: uniqueImageFeed().local, - timestamp: moreThanSevenDaysOldCacheTimestamp + timestamp: expiredTimestamp ) XCTAssertEqual(store.receivedMessages, [.retrieve]) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift index 2ccf77d..258a5e5 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift @@ -30,35 +30,35 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { XCTAssertEqual(store.receivedMessages, [.retrieve]) } - func test_validateCache_doesNotDeleteLessThanSevenDaysOldCache() { + func test_validateCache_doesNotDeleteNonExpiredCache() { let fixedCurrentDate = Date() - let lessThanSevenDaysOldTimestamp = fixedCurrentDate - .adding(days: -7) + let nonExpiredTimestamp = fixedCurrentDate + .minusFeedCacheMaxAge() .adding(seconds: 1) let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) sut.validateCache() store.completeRetrieval( with: uniqueImageFeed().local, - timestamp: lessThanSevenDaysOldTimestamp + timestamp: nonExpiredTimestamp ) XCTAssertEqual(store.receivedMessages, [.retrieve]) } - func test_validateCache_deletesSevenDaysOldCache() { + func test_validateCache_deletesCacheOnExpiration() { let fixedCurrentDate = Date() - let sevenDaysOldTimestamp = fixedCurrentDate.adding(days: -7) + let expirationTimestamp = fixedCurrentDate.minusFeedCacheMaxAge() let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) sut.validateCache() - store.completeRetrieval(with: uniqueImageFeed().local, timestamp: sevenDaysOldTimestamp) + store.completeRetrieval(with: uniqueImageFeed().local, timestamp: expirationTimestamp) XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) } - func test_validateCache_deletesMoreThanSevenDaysOldCache() { + func test_validateCache_deletesExpiredCache() { let fixedCurrentDate = Date() - let moreThanSevenDaysOldCacheTimestamp = fixedCurrentDate - .adding(days: -7) + let expiredTimestamp = fixedCurrentDate + .minusFeedCacheMaxAge() .adding(seconds: -1) let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) @@ -66,7 +66,7 @@ class ValidateFeedCacheUseCaseTests: XCTestCase { sut.validateCache() store.completeRetrieval( with: uniqueImageFeed().local, - timestamp: moreThanSevenDaysOldCacheTimestamp + timestamp: expiredTimestamp ) XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) From d7b6cf29dfbf42478c9795922bdebee89e84f2db Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 12:39:38 +0200 Subject: [PATCH 063/159] Move feed cache max age (7) days to a computer var to clarify intent in code --- .../Feed Cache/Helpers/FeedCacheTestHelpers.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift index 18c3ce8..c462849 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift @@ -27,9 +27,11 @@ func uniqueImage() -> FeedImage { extension Date { func minusFeedCacheMaxAge() -> Date { - return adding(days: -7) + return adding(days: -feedCacheMaxAgeInDays) } + private var feedCacheMaxAgeInDays: Int { 7 } + private func adding(days: Int) -> Date { return Calendar(identifier: .gregorian).date(byAdding: .day, value: days, to: self)! } From 1d282a4ebc138495efd6ed6665a104a0b5a18d76 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 12:40:51 +0200 Subject: [PATCH 064/159] Separate date extension helpers into two distinct context to clarify their scope (one is a cache policy specific DSL, the other is just a reusable DSL helper) --- .../Feed Cache/Helpers/FeedCacheTestHelpers.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift index c462849..18ca22c 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift @@ -35,7 +35,9 @@ extension Date { private func adding(days: Int) -> Date { return Calendar(identifier: .gregorian).date(byAdding: .day, value: days, to: self)! } - +} + +extension Date { func adding(seconds: TimeInterval) -> Date { return self + seconds } From b034f0ec7b920981e0100cab71a78a8353ebfb9b Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 13:57:07 +0200 Subject: [PATCH 065/159] Retrieving from empty cache delivers empty resu:t --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 +++ .../Feed Cache/CodableFeedStoreTests.swift | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 5bfbbb6..06f6c92 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 407F4FA52D9FD7F90070F56E /* ValidateFeedCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */; }; 407F4FA72D9FDB810070F56E /* FeedCacheTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */; }; 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */; }; + 407F4FAF2D9FFF680070F56E /* CodableFeedStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */; }; 40B002492CF9E9DB0058D3E0 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; @@ -49,6 +50,7 @@ 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateFeedCacheUseCaseTests.swift; sourceTree = ""; }; 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCacheTestHelpers.swift; sourceTree = ""; }; 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCachePolicy.swift; sourceTree = ""; }; + 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableFeedStoreTests.swift; sourceTree = ""; }; 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; @@ -168,6 +170,7 @@ 40B975392D9E7AD3009652B5 /* Feed Cache */ = { isa = PBXGroup; children = ( + 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */, 40B9754C2D9EC147009652B5 /* Helpers */, 40B9754A2D9EC05C009652B5 /* LoadFeedFromCacheUseCaseTests.swift */, 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */, @@ -358,6 +361,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 407F4FAF2D9FFF680070F56E /* CodableFeedStoreTests.swift in Sources */, 407F4FA52D9FD7F90070F56E /* ValidateFeedCacheUseCaseTests.swift in Sources */, 40B9754E2D9EC15A009652B5 /* FeedStoreSpy.swift in Sources */, 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */, diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift new file mode 100644 index 0000000..43781e9 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -0,0 +1,32 @@ +// +// CodableFeedStoreTests.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 4/4/25. +// + +import XCTest +import EssentialFeed + +class CodableFeedStore { + func retrieve(completion: @escaping FeedStore.RetrievalCompletion) { + completion(.empty) + } +} + +class CodableFeedStoreTests: XCTestCase { + + func test_retrieve_deliversEmptyOnEmptyCache() { + let sut = CodableFeedStore() + let exp = expectation(description: "") + sut.retrieve { result in + exp.fulfill() + switch result { + case .empty: break + default: XCTFail("Expected empty result, got \(result) instead") + } + } + + wait(for: [exp], timeout: 1.0) + } +} From 339d31a80c44bdb74ee15f02004f2a84bd55b674 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 14:00:43 +0200 Subject: [PATCH 066/159] Retrieving from empty cache twice delivers empty result (no side-effects) --- .../Feed Cache/CodableFeedStoreTests.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 43781e9..f685948 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -29,4 +29,20 @@ class CodableFeedStoreTests: XCTestCase { wait(for: [exp], timeout: 1.0) } + + func test_retrieve_hasNoSideEffectsOnEmptyCache() { + let sut = CodableFeedStore() + let exp = expectation(description: "Wait for cache retrieval") + sut.retrieve { firstResult in + sut.retrieve { secondResult in + exp.fulfill() + switch (firstResult, secondResult) { + case (.empty, .empty): break + default: XCTFail("Expected retrieving twice from empty cache to deliver empty result, got \(firstResult) and \(secondResult) instead") + } + } + } + + wait(for: [exp], timeout: 1.0) + } } From cc4e793dabbc159197a9b959499db294645c37f7 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 14:26:17 +0200 Subject: [PATCH 067/159] Retieving after inserting to empty cache delivers inserted values --- .../Feed Cache/LocalFeedImage.swift | 2 +- .../Feed Cache/CodableFeedStoreTests.swift | 48 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift index b94cfe9..ccd72c3 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift @@ -8,7 +8,7 @@ import Foundation -public struct LocalFeedImage: Equatable { +public struct LocalFeedImage: Equatable, Codable { public let id: UUID public let description: String? diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index f685948..34ed964 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -9,12 +9,38 @@ import XCTest import EssentialFeed class CodableFeedStore { + private struct Cache: Codable { + let feed: [LocalFeedImage] + let timestamp: Date + } + + private let storeURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("image-feed.store") + func retrieve(completion: @escaping FeedStore.RetrievalCompletion) { - completion(.empty) + guard let data = try? Data(contentsOf: storeURL) else { + return completion(.empty) + } + + let decoder = JSONDecoder() + let cache = try! decoder.decode(Cache.self, from: data) + completion(.found(feed: cache.feed, timestamp: cache.timestamp)) + } + + func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping FeedStore.InsertionCompletion) { + let encoder = JSONEncoder() + let encoded = try! encoder.encode(Cache(feed: feed, timestamp: timestamp)) + try! encoded.write(to: storeURL) + completion(nil) } } class CodableFeedStoreTests: XCTestCase { + + override func setUp() { + super.setUp() + let storeURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("image-feed.store") + try? FileManager.default.removeItem(at: storeURL) + } func test_retrieve_deliversEmptyOnEmptyCache() { let sut = CodableFeedStore() @@ -45,4 +71,24 @@ class CodableFeedStoreTests: XCTestCase { wait(for: [exp], timeout: 1.0) } + + func test_retrieveAfterInsertingToEmptyCache_deliversInsertedValues() { + let sut = CodableFeedStore() + let feed = uniqueImageFeed().local + let timestamp = Date() + let exp = expectation(description: "Wait for cache retrieval") + sut.insert(feed, timestamp: timestamp) { insertionError in + XCTAssertNil(insertionError) + sut.retrieve { retrieveResult in + exp.fulfill() + switch retrieveResult { + case let .found(retrievedFeed, retrievedTimestamp): + XCTAssertEqual(retrievedFeed, feed) + XCTAssertEqual(retrievedTimestamp, timestamp) + default: XCTFail("Expected retrieving from cache to deliver inserted values, got \(retrieveResult) instead") + } + } + } + wait(for: [exp], timeout: 1.0) + } } From a8bae424170b4d89d1a91af417c51cc1f1415ca7 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 14:34:23 +0200 Subject: [PATCH 068/159] Move Codable conformance from the framework agnostic LocalFeedImage type to the new framework specific CodableFeedImageType (a private type withing the framework implementation since the Codable requirement is a framework specific detail) --- .../Feed Cache/LocalFeedImage.swift | 2 +- .../Feed Cache/CodableFeedStoreTests.swift | 32 +++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift index ccd72c3..b94cfe9 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift @@ -8,7 +8,7 @@ import Foundation -public struct LocalFeedImage: Equatable, Codable { +public struct LocalFeedImage: Equatable { public let id: UUID public let description: String? diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 34ed964..bbcd8fd 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -10,8 +10,33 @@ import EssentialFeed class CodableFeedStore { private struct Cache: Codable { - let feed: [LocalFeedImage] + let feed: [CodableFeedImage] let timestamp: Date + + var localFeed: [LocalFeedImage] { + feed.map { + LocalFeedImage( + id: $0.id, + description: $0.description, + location: $0.location, + url: $0.url + ) + } + } + } + + private struct CodableFeedImage: Codable { + fileprivate let id: UUID + fileprivate let description: String? + fileprivate let location: String? + fileprivate let url: URL + + init(_ image: LocalFeedImage) { + id = image.id + description = image.description + location = image.location + url = image.url + } } private let storeURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("image-feed.store") @@ -23,12 +48,13 @@ class CodableFeedStore { let decoder = JSONDecoder() let cache = try! decoder.decode(Cache.self, from: data) - completion(.found(feed: cache.feed, timestamp: cache.timestamp)) + completion(.found(feed: cache.localFeed, timestamp: cache.timestamp)) } func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping FeedStore.InsertionCompletion) { let encoder = JSONEncoder() - let encoded = try! encoder.encode(Cache(feed: feed, timestamp: timestamp)) + let cache = Cache(feed: feed.map(CodableFeedImage.init), timestamp: timestamp) + let encoded = try! encoder.encode(cache) try! encoded.write(to: storeURL) completion(nil) } From bb31318217d1fd920e07914190d054e5052d7173 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 14:35:24 +0200 Subject: [PATCH 069/159] Extract system under testt (sut) creation into a factory method --- .../Feed Cache/CodableFeedStoreTests.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index bbcd8fd..5aa8323 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -69,7 +69,7 @@ class CodableFeedStoreTests: XCTestCase { } func test_retrieve_deliversEmptyOnEmptyCache() { - let sut = CodableFeedStore() + let sut = makeSUT() let exp = expectation(description: "") sut.retrieve { result in exp.fulfill() @@ -83,7 +83,7 @@ class CodableFeedStoreTests: XCTestCase { } func test_retrieve_hasNoSideEffectsOnEmptyCache() { - let sut = CodableFeedStore() + let sut = makeSUT() let exp = expectation(description: "Wait for cache retrieval") sut.retrieve { firstResult in sut.retrieve { secondResult in @@ -99,7 +99,7 @@ class CodableFeedStoreTests: XCTestCase { } func test_retrieveAfterInsertingToEmptyCache_deliversInsertedValues() { - let sut = CodableFeedStore() + let sut = makeSUT() let feed = uniqueImageFeed().local let timestamp = Date() let exp = expectation(description: "Wait for cache retrieval") @@ -117,4 +117,9 @@ class CodableFeedStoreTests: XCTestCase { } wait(for: [exp], timeout: 1.0) } + + // MARK: - Helpers + func makeSUT() -> CodableFeedStore { + CodableFeedStore() + } } From 25a471eedb53f73f2e1dda3618a2519afb6d3865 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 14:36:10 +0200 Subject: [PATCH 070/159] Add memory leak tracking --- .../Feed Cache/CodableFeedStoreTests.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 5aa8323..d8f0da8 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -119,7 +119,12 @@ class CodableFeedStoreTests: XCTestCase { } // MARK: - Helpers - func makeSUT() -> CodableFeedStore { - CodableFeedStore() + func makeSUT( + file: StaticString = #file, + line: UInt = #line + ) -> CodableFeedStore { + let sut = CodableFeedStore() + trackForMemoryLeaks(sut, file: file, line: line) + return sut } } From d253d4865447988841b211bf858954eefd9cbf08 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 14:38:23 +0200 Subject: [PATCH 071/159] Extract hard-coded store URL from the CodableFeedStore production type making it an explicit dependecny (passad in by the tests) --- .../Feed Cache/CodableFeedStoreTests.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index d8f0da8..8153cbb 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -39,7 +39,11 @@ class CodableFeedStore { } } - private let storeURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("image-feed.store") + private let storeURL: URL + + init(storeURL: URL) { + self.storeURL = storeURL + } func retrieve(completion: @escaping FeedStore.RetrievalCompletion) { guard let data = try? Data(contentsOf: storeURL) else { @@ -123,7 +127,8 @@ class CodableFeedStoreTests: XCTestCase { file: StaticString = #file, line: UInt = #line ) -> CodableFeedStore { - let sut = CodableFeedStore() + let storeURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("image-feed.store") + let sut = CodableFeedStore(storeURL: storeURL) trackForMemoryLeaks(sut, file: file, line: line) return sut } From b6a022e8a8efcfeb8bc4af497c1bd34328e64fd8 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 14:40:55 +0200 Subject: [PATCH 072/159] Add teardown store cleaning as an extra security layer (besides setUp) to ensure no artifacts are left on disk --- .../Feed Cache/CodableFeedStoreTests.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 8153cbb..1eb3c66 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -72,6 +72,12 @@ class CodableFeedStoreTests: XCTestCase { try? FileManager.default.removeItem(at: storeURL) } + override func tearDown() { + super.setUp() + let storeURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("image-feed.store") + try? FileManager.default.removeItem(at: storeURL) + } + func test_retrieve_deliversEmptyOnEmptyCache() { let sut = makeSUT() let exp = expectation(description: "") From a6da4d51922e8af9c152ee3b94cbb2f94e1db49b Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 14:42:53 +0200 Subject: [PATCH 073/159] Extract duplicated store URL creation into a helper factory method --- .../Feed Cache/CodableFeedStoreTests.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 1eb3c66..519b855 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -68,13 +68,13 @@ class CodableFeedStoreTests: XCTestCase { override func setUp() { super.setUp() - let storeURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("image-feed.store") + let storeURL = storeURL() try? FileManager.default.removeItem(at: storeURL) } override func tearDown() { super.setUp() - let storeURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("image-feed.store") + let storeURL = storeURL() try? FileManager.default.removeItem(at: storeURL) } @@ -129,13 +129,16 @@ class CodableFeedStoreTests: XCTestCase { } // MARK: - Helpers - func makeSUT( + private func makeSUT( file: StaticString = #file, line: UInt = #line ) -> CodableFeedStore { - let storeURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("image-feed.store") - let sut = CodableFeedStore(storeURL: storeURL) + let sut = CodableFeedStore(storeURL: storeURL()) trackForMemoryLeaks(sut, file: file, line: line) return sut } + + private func storeURL() -> URL { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("image-feed.store") + } } From e64aa09b4e8a924ba3327ef2927b1bd451cbefa7 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 14:44:55 +0200 Subject: [PATCH 074/159] Replace production storeURL with a test specific storeURL to avoid sharing state or artifacts with other parts of the system (includiing other tests) --- .../Feed Cache/CodableFeedStoreTests.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 519b855..2fb619b 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -68,13 +68,13 @@ class CodableFeedStoreTests: XCTestCase { override func setUp() { super.setUp() - let storeURL = storeURL() + let storeURL = testSpecificStoreURL() try? FileManager.default.removeItem(at: storeURL) } override func tearDown() { super.setUp() - let storeURL = storeURL() + let storeURL = testSpecificStoreURL() try? FileManager.default.removeItem(at: storeURL) } @@ -133,12 +133,12 @@ class CodableFeedStoreTests: XCTestCase { file: StaticString = #file, line: UInt = #line ) -> CodableFeedStore { - let sut = CodableFeedStore(storeURL: storeURL()) + let sut = CodableFeedStore(storeURL: testSpecificStoreURL()) trackForMemoryLeaks(sut, file: file, line: line) return sut } - private func storeURL() -> URL { - FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("image-feed.store") + private func testSpecificStoreURL() -> URL { + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("\(type(of: self)).store") } } From 48d46bd4de34aad85831e50e6c65163b922abdb9 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 14:51:27 +0200 Subject: [PATCH 075/159] Add helper method to provide documentation context and clarify test setup and tearDown intent regarding side-effects --- .../Feed Cache/CodableFeedStoreTests.swift | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 2fb619b..1bfa3aa 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -68,16 +68,14 @@ class CodableFeedStoreTests: XCTestCase { override func setUp() { super.setUp() - let storeURL = testSpecificStoreURL() - try? FileManager.default.removeItem(at: storeURL) + setupEmptyStoreState() } override func tearDown() { super.setUp() - let storeURL = testSpecificStoreURL() - try? FileManager.default.removeItem(at: storeURL) + undoStoreSideEfects() } - + func test_retrieve_deliversEmptyOnEmptyCache() { let sut = makeSUT() let exp = expectation(description: "") @@ -141,4 +139,16 @@ class CodableFeedStoreTests: XCTestCase { private func testSpecificStoreURL() -> URL { FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("\(type(of: self)).store") } + + private func setupEmptyStoreState() { + deleteStoreArtifacts() + } + + private func undoStoreSideEfects() { + deleteStoreArtifacts() + } + + private func deleteStoreArtifacts() { + try? FileManager.default.removeItem(at: testSpecificStoreURL()) + } } From ba6b07813e48d3cc466e0c1f57f05aa728a609ce Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 15:03:30 +0200 Subject: [PATCH 076/159] Retrieving from non empty cache twice delivers same found result (no side-effects) --- .../Feed Cache/CodableFeedStoreTests.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 1bfa3aa..fdc2245 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -126,6 +126,32 @@ class CodableFeedStoreTests: XCTestCase { wait(for: [exp], timeout: 1.0) } + func test_retrieve_hasNoSideEffectsOnNonEmptyCache() { + let sut = makeSUT() + let feed = uniqueImageFeed().local + let timestamp = Date() + let exp = expectation(description: "Wait for cache insertion and double retrieval") + sut.insert(feed, timestamp: timestamp) { insertionError in + XCTAssertNil(insertionError) + sut.retrieve { firstResult in + sut.retrieve { secondResult in + exp.fulfill() + switch (firstResult, secondResult) { + case let (.found(firstFeed, firstTimestamp), .found(secondFeed, secondTimestamp)): + XCTAssertEqual(firstFeed, feed) + XCTAssertEqual(firstTimestamp, timestamp) + + XCTAssertEqual(secondFeed, feed) + XCTAssertEqual(secondTimestamp, timestamp) + default: XCTFail("Expected retrieving twice from non empty cache to deliver same found result with feed \(feed) and timestamp \(timestamp), got \(firstResult) and \(secondResult) instead") + } + } + } + } + + wait(for: [exp], timeout: 1.0) + } + // MARK: - Helpers private func makeSUT( file: StaticString = #file, From 13f5f3e230b4767d43a35ed0ad82e75ba984f532 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 15:06:42 +0200 Subject: [PATCH 077/159] Add missing description for expectation --- .../EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index fdc2245..8856c42 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -78,7 +78,7 @@ class CodableFeedStoreTests: XCTestCase { func test_retrieve_deliversEmptyOnEmptyCache() { let sut = makeSUT() - let exp = expectation(description: "") + let exp = expectation(description: "Wait for cache retrieval") sut.retrieve { result in exp.fulfill() switch result { From ac44e114973e2e95b1b7bcc560d7a217a021ae11 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 15:14:06 +0200 Subject: [PATCH 078/159] Extract the duplicated retrieve test code into a reusable helper method --- .../Feed Cache/CodableFeedStoreTests.swift | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 8856c42..2a24556 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -78,16 +78,7 @@ class CodableFeedStoreTests: XCTestCase { func test_retrieve_deliversEmptyOnEmptyCache() { let sut = makeSUT() - let exp = expectation(description: "Wait for cache retrieval") - sut.retrieve { result in - exp.fulfill() - switch result { - case .empty: break - default: XCTFail("Expected empty result, got \(result) instead") - } - } - - wait(for: [exp], timeout: 1.0) + expect(sut, toRetrieve: .empty) } func test_retrieve_hasNoSideEffectsOnEmptyCache() { @@ -110,26 +101,22 @@ class CodableFeedStoreTests: XCTestCase { let sut = makeSUT() let feed = uniqueImageFeed().local let timestamp = Date() + let exp = expectation(description: "Wait for cache retrieval") sut.insert(feed, timestamp: timestamp) { insertionError in XCTAssertNil(insertionError) - sut.retrieve { retrieveResult in - exp.fulfill() - switch retrieveResult { - case let .found(retrievedFeed, retrievedTimestamp): - XCTAssertEqual(retrievedFeed, feed) - XCTAssertEqual(retrievedTimestamp, timestamp) - default: XCTFail("Expected retrieving from cache to deliver inserted values, got \(retrieveResult) instead") - } - } + exp.fulfill() } wait(for: [exp], timeout: 1.0) + + expect(sut, toRetrieve: .found(feed: feed, timestamp: timestamp)) } func test_retrieve_hasNoSideEffectsOnNonEmptyCache() { let sut = makeSUT() let feed = uniqueImageFeed().local let timestamp = Date() + let exp = expectation(description: "Wait for cache insertion and double retrieval") sut.insert(feed, timestamp: timestamp) { insertionError in XCTAssertNil(insertionError) @@ -162,6 +149,28 @@ class CodableFeedStoreTests: XCTestCase { return sut } + private func expect( + _ sut: CodableFeedStore, + toRetrieve expectedResult: RetrieveCachedFeedResult, + file: StaticString = #file, + line: UInt = #line + ) { + let exp = expectation(description: "Wait for cache retrieval") + + sut.retrieve { retrievedResult in + exp.fulfill() + switch (expectedResult, retrievedResult) { + case (.empty, .empty): break + case let (.found(expectedFeed, expectedTimestmap), .found(retrievedFeed, retrievedTimestamp)): + XCTAssertEqual(expectedFeed, retrievedFeed, file: file, line: line) + XCTAssertEqual(expectedTimestmap, retrievedTimestamp, file: file, line: line) + default: + XCTFail("Expected to retrieve \(expectedResult), but got \(retrievedResult)", file: file, line: line) + } + } + wait(for: [exp], timeout: 1.0) + } + private func testSpecificStoreURL() -> URL { FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("\(type(of: self)).store") } From 2b0cf2622f4f8b6f0a424876b2160018cff37943 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 15:19:00 +0200 Subject: [PATCH 079/159] Extract duplicated non-side effects-on-retrieve test code into a reusable helper method --- .../Feed Cache/CodableFeedStoreTests.swift | 41 +++++++------------ 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 2a24556..beef31f 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -83,18 +83,7 @@ class CodableFeedStoreTests: XCTestCase { func test_retrieve_hasNoSideEffectsOnEmptyCache() { let sut = makeSUT() - let exp = expectation(description: "Wait for cache retrieval") - sut.retrieve { firstResult in - sut.retrieve { secondResult in - exp.fulfill() - switch (firstResult, secondResult) { - case (.empty, .empty): break - default: XCTFail("Expected retrieving twice from empty cache to deliver empty result, got \(firstResult) and \(secondResult) instead") - } - } - } - - wait(for: [exp], timeout: 1.0) + expect(sut, toRetrieveTwice: .empty) } func test_retrieveAfterInsertingToEmptyCache_deliversInsertedValues() { @@ -117,26 +106,14 @@ class CodableFeedStoreTests: XCTestCase { let feed = uniqueImageFeed().local let timestamp = Date() - let exp = expectation(description: "Wait for cache insertion and double retrieval") + let exp = expectation(description: "Wait for cache insertion") sut.insert(feed, timestamp: timestamp) { insertionError in XCTAssertNil(insertionError) - sut.retrieve { firstResult in - sut.retrieve { secondResult in - exp.fulfill() - switch (firstResult, secondResult) { - case let (.found(firstFeed, firstTimestamp), .found(secondFeed, secondTimestamp)): - XCTAssertEqual(firstFeed, feed) - XCTAssertEqual(firstTimestamp, timestamp) - - XCTAssertEqual(secondFeed, feed) - XCTAssertEqual(secondTimestamp, timestamp) - default: XCTFail("Expected retrieving twice from non empty cache to deliver same found result with feed \(feed) and timestamp \(timestamp), got \(firstResult) and \(secondResult) instead") - } - } - } + exp.fulfill() } wait(for: [exp], timeout: 1.0) + expect(sut, toRetrieveTwice: .found(feed: feed, timestamp: timestamp)) } // MARK: - Helpers @@ -171,6 +148,16 @@ class CodableFeedStoreTests: XCTestCase { wait(for: [exp], timeout: 1.0) } + private func expect( + _ sut: CodableFeedStore, + toRetrieveTwice expectedResult: RetrieveCachedFeedResult, + file: StaticString = #file, + line: UInt = #line + ) { + expect(sut, toRetrieve: expectedResult, file: file, line: line) + expect(sut, toRetrieve: expectedResult, file: file, line: line) + } + private func testSpecificStoreURL() -> URL { FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("\(type(of: self)).store") } From 26ee57e4f1a7cb2c5f1582e8024f1b5ddb619632 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 15:23:29 +0200 Subject: [PATCH 080/159] Extract duplicate insert test code into a reusable helper method --- .../Feed Cache/CodableFeedStoreTests.swift | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index beef31f..7c97242 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -91,13 +91,7 @@ class CodableFeedStoreTests: XCTestCase { let feed = uniqueImageFeed().local let timestamp = Date() - let exp = expectation(description: "Wait for cache retrieval") - sut.insert(feed, timestamp: timestamp) { insertionError in - XCTAssertNil(insertionError) - exp.fulfill() - } - wait(for: [exp], timeout: 1.0) - + insert((feed, timestamp), to: sut) expect(sut, toRetrieve: .found(feed: feed, timestamp: timestamp)) } @@ -106,13 +100,7 @@ class CodableFeedStoreTests: XCTestCase { let feed = uniqueImageFeed().local let timestamp = Date() - let exp = expectation(description: "Wait for cache insertion") - sut.insert(feed, timestamp: timestamp) { insertionError in - XCTAssertNil(insertionError) - exp.fulfill() - } - - wait(for: [exp], timeout: 1.0) + insert((feed, timestamp), to: sut) expect(sut, toRetrieveTwice: .found(feed: feed, timestamp: timestamp)) } @@ -126,6 +114,16 @@ class CodableFeedStoreTests: XCTestCase { return sut } + private func insert(_ cache: (feed: [LocalFeedImage], timestamp: Date), to sut: CodableFeedStore) { + let exp = expectation(description: "Wait for cache insertion") + sut.insert(cache.feed, timestamp: cache.timestamp) { insertionError in + XCTAssertNil(insertionError) + exp.fulfill() + } + + wait(for: [exp], timeout: 1.0) + } + private func expect( _ sut: CodableFeedStore, toRetrieve expectedResult: RetrieveCachedFeedResult, From 138ea634f639cf48a2b776b8b7c74beed5dac9fb Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 15:25:04 +0200 Subject: [PATCH 081/159] Improve test name to follow convention --- .../EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 7c97242..93eb576 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -86,7 +86,7 @@ class CodableFeedStoreTests: XCTestCase { expect(sut, toRetrieveTwice: .empty) } - func test_retrieveAfterInsertingToEmptyCache_deliversInsertedValues() { + func test_retrieve_deliversFoundValuesOnNonEmptyCache() { let sut = makeSUT() let feed = uniqueImageFeed().local let timestamp = Date() From 3649db808ca428c7dece56adc3981437c909fcc5 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 15:28:10 +0200 Subject: [PATCH 082/159] Retrieve delivers error on retrieval error (invalid cached data in this case) --- .../Feed Cache/CodableFeedStoreTests.swift | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 93eb576..7945f23 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -47,12 +47,16 @@ class CodableFeedStore { func retrieve(completion: @escaping FeedStore.RetrievalCompletion) { guard let data = try? Data(contentsOf: storeURL) else { - return completion(.empty) + return completion(.empty) } - let decoder = JSONDecoder() - let cache = try! decoder.decode(Cache.self, from: data) - completion(.found(feed: cache.localFeed, timestamp: cache.timestamp)) + do { + let decoder = JSONDecoder() + let cache = try decoder.decode(Cache.self, from: data) + completion(.found(feed: cache.localFeed, timestamp: cache.timestamp)) + } catch { + completion(.failure(error)) + } } func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping FeedStore.InsertionCompletion) { @@ -65,7 +69,7 @@ class CodableFeedStore { } class CodableFeedStoreTests: XCTestCase { - + override func setUp() { super.setUp() setupEmptyStoreState() @@ -75,7 +79,7 @@ class CodableFeedStoreTests: XCTestCase { super.setUp() undoStoreSideEfects() } - + func test_retrieve_deliversEmptyOnEmptyCache() { let sut = makeSUT() expect(sut, toRetrieve: .empty) @@ -104,6 +108,13 @@ class CodableFeedStoreTests: XCTestCase { expect(sut, toRetrieveTwice: .found(feed: feed, timestamp: timestamp)) } + func test_retrieve_deliversFailureOnRetrievalError() { + let sut = makeSUT() + try! "invalid data".write(to: testSpecificStoreURL(), atomically: false, encoding: .utf8) + + expect(sut, toRetrieve: .failure(anyNSError())) + } + // MARK: - Helpers private func makeSUT( file: StaticString = #file, @@ -135,7 +146,8 @@ class CodableFeedStoreTests: XCTestCase { sut.retrieve { retrievedResult in exp.fulfill() switch (expectedResult, retrievedResult) { - case (.empty, .empty): break + case (.empty, .empty), (.failure, .failure): + break case let (.found(expectedFeed, expectedTimestmap), .found(retrievedFeed, retrievedTimestamp)): XCTAssertEqual(expectedFeed, retrievedFeed, file: file, line: line) XCTAssertEqual(expectedTimestmap, retrievedTimestamp, file: file, line: line) From af91572c9dfc8fee0aa7040666ae8e898cd309bf Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 15:31:10 +0200 Subject: [PATCH 083/159] Make the storeURL explicit within the test to facilitate debugging if this test ever fails (all relevant details within a test method should be clearly visible) --- .../Feed Cache/CodableFeedStoreTests.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 7945f23..2feb3e6 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -109,18 +109,21 @@ class CodableFeedStoreTests: XCTestCase { } func test_retrieve_deliversFailureOnRetrievalError() { - let sut = makeSUT() - try! "invalid data".write(to: testSpecificStoreURL(), atomically: false, encoding: .utf8) + let storeURL = testSpecificStoreURL() + let sut = makeSUT(storeURL: storeURL) + + try! "invalid data".write(to: storeURL, atomically: false, encoding: .utf8) expect(sut, toRetrieve: .failure(anyNSError())) } // MARK: - Helpers private func makeSUT( + storeURL: URL? = nil, file: StaticString = #file, line: UInt = #line ) -> CodableFeedStore { - let sut = CodableFeedStore(storeURL: testSpecificStoreURL()) + let sut = CodableFeedStore(storeURL: storeURL ?? testSpecificStoreURL()) trackForMemoryLeaks(sut, file: file, line: line) return sut } From ca4121de2440aa3be7716c1110045580e128c64f Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 15:32:49 +0200 Subject: [PATCH 084/159] Retrieving from invalid cache twice delivers same error (no side-effects) --- .../Feed Cache/CodableFeedStoreTests.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 2feb3e6..0b61bae 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -117,6 +117,15 @@ class CodableFeedStoreTests: XCTestCase { expect(sut, toRetrieve: .failure(anyNSError())) } + func test_retrieve_hasNoSideEffectsOnFailure() { + let storeURL = testSpecificStoreURL() + let sut = makeSUT(storeURL: storeURL) + + try! "invalid data".write(to: storeURL, atomically: false, encoding: .utf8) + + expect(sut, toRetrieveTwice: .failure(anyNSError())) + } + // MARK: - Helpers private func makeSUT( storeURL: URL? = nil, From 352a683b080f22e36ca4915dcfcde0d82aa31ba8 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 15:38:17 +0200 Subject: [PATCH 085/159] Inserting to a non-empty cache overrides the previously inserted cache value --- .../Feed Cache/CodableFeedStoreTests.swift | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 0b61bae..e415711 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -126,6 +126,20 @@ class CodableFeedStoreTests: XCTestCase { expect(sut, toRetrieveTwice: .failure(anyNSError())) } + func test_insert_overridesPreviouslyInsertedCacheValues() { + let sut = makeSUT() + + let firstInsertionError = insert((uniqueImageFeed().local, Date()), to: sut) + XCTAssertNil(firstInsertionError, "Expected to insert cache succesfully") + + let latestFeed = uniqueImageFeed().local + let latesTimestamp = Date() + let latestInsertionError = insert((latestFeed, latesTimestamp), to: sut) + + XCTAssertNil(latestInsertionError, "Expected to override cache succesfully") + expect(sut, toRetrieve: .found(feed: latestFeed, timestamp: latesTimestamp)) + } + // MARK: - Helpers private func makeSUT( storeURL: URL? = nil, @@ -137,14 +151,17 @@ class CodableFeedStoreTests: XCTestCase { return sut } - private func insert(_ cache: (feed: [LocalFeedImage], timestamp: Date), to sut: CodableFeedStore) { + @discardableResult + private func insert(_ cache: (feed: [LocalFeedImage], timestamp: Date), to sut: CodableFeedStore) -> Error? { let exp = expectation(description: "Wait for cache insertion") - sut.insert(cache.feed, timestamp: cache.timestamp) { insertionError in - XCTAssertNil(insertionError) + var insertionError: Error? + sut.insert(cache.feed, timestamp: cache.timestamp) { error in + insertionError = error exp.fulfill() } wait(for: [exp], timeout: 1.0) + return insertionError } private func expect( From 6ea1cd62dab3bcd86fbe31355c0f94a03e10a7c3 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 15:41:05 +0200 Subject: [PATCH 086/159] Insert delivers error on insertion error (invalid store url) --- .../Feed Cache/CodableFeedStoreTests.swift | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index e415711..566fe97 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -60,11 +60,15 @@ class CodableFeedStore { } func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping FeedStore.InsertionCompletion) { - let encoder = JSONEncoder() - let cache = Cache(feed: feed.map(CodableFeedImage.init), timestamp: timestamp) - let encoded = try! encoder.encode(cache) - try! encoded.write(to: storeURL) - completion(nil) + do { + let encoder = JSONEncoder() + let cache = Cache(feed: feed.map(CodableFeedImage.init), timestamp: timestamp) + let encoded = try encoder.encode(cache) + try encoded.write(to: storeURL) + completion(nil) + } catch { + completion(error) + } } } @@ -140,6 +144,15 @@ class CodableFeedStoreTests: XCTestCase { expect(sut, toRetrieve: .found(feed: latestFeed, timestamp: latesTimestamp)) } + func test_insert_deliversErrorOnInsertionError() { + let invalidStoreURL = URL(string: "invalid://store-url") + let sut = makeSUT(storeURL: invalidStoreURL) + let feed = uniqueImageFeed().local + let timestamp = Date() + let insertionError = insert((feed, timestamp), to: sut) + XCTAssertNotNil(insertionError, "Expected cache insertion to fail ith an error") + } + // MARK: - Helpers private func makeSUT( storeURL: URL? = nil, From 1d71c0168b2cab17a87a3377a860359ad5df62d0 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 15:44:49 +0200 Subject: [PATCH 087/159] Delete comand has no side effects on empty cache --- .../Feed Cache/CodableFeedStoreTests.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 566fe97..fc7cd5f 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -70,6 +70,10 @@ class CodableFeedStore { completion(error) } } + + func deleteCachedFeed(completion: @escaping FeedStore.DeletionCompletion) { + completion(nil) + } } class CodableFeedStoreTests: XCTestCase { @@ -153,6 +157,17 @@ class CodableFeedStoreTests: XCTestCase { XCTAssertNotNil(insertionError, "Expected cache insertion to fail ith an error") } + func test_delete_hasNoSideEffectsOnEmptyCache() { + let sut = makeSUT() + let exp = expectation(description: "Wait for cache deletion") + sut.deleteCachedFeed { _ in + exp.fulfill() + } + + wait(for: [exp], timeout: 1.0) + expect(sut, toRetrieve: .empty) + } + // MARK: - Helpers private func makeSUT( storeURL: URL? = nil, From 84e18d3c72207c29173d1138f93e1410ca63b446 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 15:48:46 +0200 Subject: [PATCH 088/159] Delete command empties previously inserted cache --- .../Feed Cache/CodableFeedStoreTests.swift | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index fc7cd5f..2a3e40b 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -72,7 +72,12 @@ class CodableFeedStore { } func deleteCachedFeed(completion: @escaping FeedStore.DeletionCompletion) { - completion(nil) + do { + try FileManager.default.removeItem(at: storeURL) + completion(nil) + } catch { + completion(error) + } } } @@ -168,6 +173,19 @@ class CodableFeedStoreTests: XCTestCase { expect(sut, toRetrieve: .empty) } + func test_delete_emptiesPreviouslyInsertedCache() { + let sut = makeSUT() + insert((uniqueImageFeed().local, Date()), to: sut) + + let exp = expectation(description: "Wait for cache deletion") + sut.deleteCachedFeed { _ in + exp.fulfill() + } + + wait(for: [exp], timeout: 1.0) + expect(sut, toRetrieve: .empty) + } + // MARK: - Helpers private func makeSUT( storeURL: URL? = nil, From 48bdcc6e00ad00f0f471e536ff2b5932a82174b5 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 16:04:49 +0200 Subject: [PATCH 089/159] Delete command delviers error on deletion error --- .../Feed Cache/CodableFeedStoreTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 2a3e40b..cc69e28 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -186,6 +186,18 @@ class CodableFeedStoreTests: XCTestCase { expect(sut, toRetrieve: .empty) } + func test_delete_deliversErrorOnDeletionError() { + let noDeletePermissionURL = FileManager.default.urls(for: .cachesDirectory, in: .systemDomainMask).first + let sut = makeSUT(storeURL: noDeletePermissionURL) + let exp = expectation(description: "Wait for deletion") + sut.deleteCachedFeed { + exp.fulfill() + XCTAssertNotNil($0) + } + + wait(for: [exp], timeout: 1.0) + } + // MARK: - Helpers private func makeSUT( storeURL: URL? = nil, From 04c308f31957759473ecd4d442fa5cbe2082d495 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 16:13:44 +0200 Subject: [PATCH 090/159] Abstract delete cache test logic into a shared reusable helper --- .../Feed Cache/CodableFeedStoreTests.swift | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index cc69e28..bf26546 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -72,6 +72,10 @@ class CodableFeedStore { } func deleteCachedFeed(completion: @escaping FeedStore.DeletionCompletion) { + guard FileManager.default.fileExists(atPath: storeURL.path) else { + return completion(nil) + } + do { try FileManager.default.removeItem(at: storeURL) completion(nil) @@ -164,38 +168,25 @@ class CodableFeedStoreTests: XCTestCase { func test_delete_hasNoSideEffectsOnEmptyCache() { let sut = makeSUT() - let exp = expectation(description: "Wait for cache deletion") - sut.deleteCachedFeed { _ in - exp.fulfill() - } + let deletionError = deleteCache(from: sut) - wait(for: [exp], timeout: 1.0) + XCTAssertNil(deletionError, "Expected empty cache deletion to succeed") expect(sut, toRetrieve: .empty) } func test_delete_emptiesPreviouslyInsertedCache() { let sut = makeSUT() insert((uniqueImageFeed().local, Date()), to: sut) - - let exp = expectation(description: "Wait for cache deletion") - sut.deleteCachedFeed { _ in - exp.fulfill() - } - - wait(for: [exp], timeout: 1.0) + let deletionError = deleteCache(from: sut) + XCTAssertNil(deletionError, "Expected non-empty cache deletion to succeed") expect(sut, toRetrieve: .empty) } func test_delete_deliversErrorOnDeletionError() { let noDeletePermissionURL = FileManager.default.urls(for: .cachesDirectory, in: .systemDomainMask).first let sut = makeSUT(storeURL: noDeletePermissionURL) - let exp = expectation(description: "Wait for deletion") - sut.deleteCachedFeed { - exp.fulfill() - XCTAssertNotNil($0) - } - - wait(for: [exp], timeout: 1.0) + let deletionError = deleteCache(from: sut) + XCTAssertNotNil(deletionError, "Expect cache deletion to fail") } // MARK: - Helpers @@ -222,6 +213,19 @@ class CodableFeedStoreTests: XCTestCase { return insertionError } + @discardableResult + private func deleteCache(from sut: CodableFeedStore) -> Error? { + let exp = expectation(description: "Wait for cache deletion") + var receivedError: Error? + sut.deleteCachedFeed { + exp.fulfill() + receivedError = $0 + } + + wait(for: [exp], timeout: 1.0) + return receivedError + } + private func expect( _ sut: CodableFeedStore, toRetrieve expectedResult: RetrieveCachedFeedResult, From 6d36c8fe224f6df390a307123d334fd28d510b03 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 16:16:05 +0200 Subject: [PATCH 091/159] Move system mask caches directory instantiation into a factory method to make code more self documented --- .../Feed Cache/CodableFeedStoreTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index bf26546..03f554f 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -183,7 +183,7 @@ class CodableFeedStoreTests: XCTestCase { } func test_delete_deliversErrorOnDeletionError() { - let noDeletePermissionURL = FileManager.default.urls(for: .cachesDirectory, in: .systemDomainMask).first + let noDeletePermissionURL = systemMaskCachesDirectory() let sut = makeSUT(storeURL: noDeletePermissionURL) let deletionError = deleteCache(from: sut) XCTAssertNotNil(deletionError, "Expect cache deletion to fail") @@ -259,6 +259,10 @@ class CodableFeedStoreTests: XCTestCase { expect(sut, toRetrieve: expectedResult, file: file, line: line) } + private func systemMaskCachesDirectory() -> URL { + FileManager.default.urls(for: .cachesDirectory, in: .systemDomainMask).first! + } + private func testSpecificStoreURL() -> URL { FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("\(type(of: self)).store") } From d85553c85e4449af76b4440401cdbda53fae692c Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 16:17:43 +0200 Subject: [PATCH 092/159] Add spacing in tests to reflect Given-When-Then structure --- .../EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 03f554f..260ad85 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -177,7 +177,9 @@ class CodableFeedStoreTests: XCTestCase { func test_delete_emptiesPreviouslyInsertedCache() { let sut = makeSUT() insert((uniqueImageFeed().local, Date()), to: sut) + let deletionError = deleteCache(from: sut) + XCTAssertNil(deletionError, "Expected non-empty cache deletion to succeed") expect(sut, toRetrieve: .empty) } @@ -185,7 +187,9 @@ class CodableFeedStoreTests: XCTestCase { func test_delete_deliversErrorOnDeletionError() { let noDeletePermissionURL = systemMaskCachesDirectory() let sut = makeSUT(storeURL: noDeletePermissionURL) + let deletionError = deleteCache(from: sut) + XCTAssertNotNil(deletionError, "Expect cache deletion to fail") } From d8a581e36a9851742db2d0bfeeab5d7d12f301be Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 16:19:14 +0200 Subject: [PATCH 093/159] Make the CodableFeedStore conform to FeedStore --- .../Feed Cache/CodableFeedStoreTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 260ad85..eeb36d8 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -8,7 +8,7 @@ import XCTest import EssentialFeed -class CodableFeedStore { +class CodableFeedStore: FeedStore { private struct Cache: Codable { let feed: [CodableFeedImage] let timestamp: Date @@ -45,7 +45,7 @@ class CodableFeedStore { self.storeURL = storeURL } - func retrieve(completion: @escaping FeedStore.RetrievalCompletion) { + func retrieve(completion: @escaping RetrievalCompletion) { guard let data = try? Data(contentsOf: storeURL) else { return completion(.empty) } @@ -59,7 +59,7 @@ class CodableFeedStore { } } - func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping FeedStore.InsertionCompletion) { + func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { do { let encoder = JSONEncoder() let cache = Cache(feed: feed.map(CodableFeedImage.init), timestamp: timestamp) @@ -71,7 +71,7 @@ class CodableFeedStore { } } - func deleteCachedFeed(completion: @escaping FeedStore.DeletionCompletion) { + func deleteCachedFeed(completion: @escaping DeletionCompletion) { guard FileManager.default.fileExists(atPath: storeURL.path) else { return completion(nil) } From bd54559621abbbbed5229dce844ab15d63ef9593 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 16:22:29 +0200 Subject: [PATCH 094/159] Replace concrete CodableFeedStore dependency in test with the FeedStore protocol to make the tests more docupled from the concrete production implementation (also proving we respect the Liskov Sustitution Principle) --- .../Feed Cache/CodableFeedStoreTests.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index eeb36d8..5ac62f8 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -198,14 +198,14 @@ class CodableFeedStoreTests: XCTestCase { storeURL: URL? = nil, file: StaticString = #file, line: UInt = #line - ) -> CodableFeedStore { + ) -> FeedStore { let sut = CodableFeedStore(storeURL: storeURL ?? testSpecificStoreURL()) trackForMemoryLeaks(sut, file: file, line: line) return sut } @discardableResult - private func insert(_ cache: (feed: [LocalFeedImage], timestamp: Date), to sut: CodableFeedStore) -> Error? { + private func insert(_ cache: (feed: [LocalFeedImage], timestamp: Date), to sut: FeedStore) -> Error? { let exp = expectation(description: "Wait for cache insertion") var insertionError: Error? sut.insert(cache.feed, timestamp: cache.timestamp) { error in @@ -218,7 +218,7 @@ class CodableFeedStoreTests: XCTestCase { } @discardableResult - private func deleteCache(from sut: CodableFeedStore) -> Error? { + private func deleteCache(from sut: FeedStore) -> Error? { let exp = expectation(description: "Wait for cache deletion") var receivedError: Error? sut.deleteCachedFeed { @@ -231,7 +231,7 @@ class CodableFeedStoreTests: XCTestCase { } private func expect( - _ sut: CodableFeedStore, + _ sut: FeedStore, toRetrieve expectedResult: RetrieveCachedFeedResult, file: StaticString = #file, line: UInt = #line @@ -254,7 +254,7 @@ class CodableFeedStoreTests: XCTestCase { } private func expect( - _ sut: CodableFeedStore, + _ sut: FeedStore, toRetrieveTwice expectedResult: RetrieveCachedFeedResult, file: StaticString = #file, line: UInt = #line From 1df730c4122ef0db7af6fc06084c95466e6bbece Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Fri, 4 Apr 2025 16:24:44 +0200 Subject: [PATCH 095/159] Move CodableFeedStore to its own file in production --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 + .../Feed Cache/CodableFeedStore.swift | 86 +++++++++++++++++++ .../Feed Cache/CodableFeedStoreTests.swift | 77 +---------------- 3 files changed, 91 insertions(+), 76 deletions(-) create mode 100644 EssentialFeed/EssentialFeed/Feed Cache/CodableFeedStore.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 06f6c92..a7f353a 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 407F4FA72D9FDB810070F56E /* FeedCacheTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */; }; 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */; }; 407F4FAF2D9FFF680070F56E /* CodableFeedStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */; }; + 407F4FB22DA022FF0070F56E /* CodableFeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FB02DA022C00070F56E /* CodableFeedStore.swift */; }; 40B002492CF9E9DB0058D3E0 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; @@ -51,6 +52,7 @@ 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCacheTestHelpers.swift; sourceTree = ""; }; 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCachePolicy.swift; sourceTree = ""; }; 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableFeedStoreTests.swift; sourceTree = ""; }; + 407F4FB02DA022C00070F56E /* CodableFeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableFeedStore.swift; sourceTree = ""; }; 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; @@ -186,6 +188,7 @@ 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */, 40B975442D9EA01D009652B5 /* FeedStore.swift */, 40B975482D9EA7A2009652B5 /* LocalFeedImage.swift */, + 407F4FB02DA022C00070F56E /* CodableFeedStore.swift */, ); path = "Feed Cache"; sourceTree = ""; @@ -353,6 +356,7 @@ 40B975492D9EA7A2009652B5 /* LocalFeedImage.swift in Sources */, 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */, 40B975452D9EA01D009652B5 /* FeedStore.swift in Sources */, + 407F4FB22DA022FF0070F56E /* CodableFeedStore.swift in Sources */, 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CodableFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CodableFeedStore.swift new file mode 100644 index 0000000..ca0a30d --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/CodableFeedStore.swift @@ -0,0 +1,86 @@ +// +// CodableFeedStore.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 4/4/25. +// + + +import Foundation + +public class CodableFeedStore: FeedStore { + private struct Cache: Codable { + let feed: [CodableFeedImage] + let timestamp: Date + + var localFeed: [LocalFeedImage] { + feed.map { + LocalFeedImage( + id: $0.id, + description: $0.description, + location: $0.location, + url: $0.url + ) + } + } + } + + private struct CodableFeedImage: Codable { + fileprivate let id: UUID + fileprivate let description: String? + fileprivate let location: String? + fileprivate let url: URL + + init(_ image: LocalFeedImage) { + id = image.id + description = image.description + location = image.location + url = image.url + } + } + + private let storeURL: URL + + public init(storeURL: URL) { + self.storeURL = storeURL + } + + public func retrieve(completion: @escaping RetrievalCompletion) { + guard let data = try? Data(contentsOf: storeURL) else { + return completion(.empty) + } + + do { + let decoder = JSONDecoder() + let cache = try decoder.decode(Cache.self, from: data) + completion(.found(feed: cache.localFeed, timestamp: cache.timestamp)) + } catch { + completion(.failure(error)) + } + } + + public func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { + do { + let encoder = JSONEncoder() + let cache = Cache(feed: feed.map(CodableFeedImage.init), timestamp: timestamp) + let encoded = try encoder.encode(cache) + try encoded.write(to: storeURL) + completion(nil) + } catch { + completion(error) + } + } + + public func deleteCachedFeed(completion: @escaping DeletionCompletion) { + guard FileManager.default.fileExists(atPath: storeURL.path) else { + return completion(nil) + } + + do { + try FileManager.default.removeItem(at: storeURL) + completion(nil) + } catch { + completion(error) + } + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 5ac62f8..2aac366 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -8,82 +8,7 @@ import XCTest import EssentialFeed -class CodableFeedStore: FeedStore { - private struct Cache: Codable { - let feed: [CodableFeedImage] - let timestamp: Date - - var localFeed: [LocalFeedImage] { - feed.map { - LocalFeedImage( - id: $0.id, - description: $0.description, - location: $0.location, - url: $0.url - ) - } - } - } - - private struct CodableFeedImage: Codable { - fileprivate let id: UUID - fileprivate let description: String? - fileprivate let location: String? - fileprivate let url: URL - - init(_ image: LocalFeedImage) { - id = image.id - description = image.description - location = image.location - url = image.url - } - } - - private let storeURL: URL - - init(storeURL: URL) { - self.storeURL = storeURL - } - - func retrieve(completion: @escaping RetrievalCompletion) { - guard let data = try? Data(contentsOf: storeURL) else { - return completion(.empty) - } - - do { - let decoder = JSONDecoder() - let cache = try decoder.decode(Cache.self, from: data) - completion(.found(feed: cache.localFeed, timestamp: cache.timestamp)) - } catch { - completion(.failure(error)) - } - } - - func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { - do { - let encoder = JSONEncoder() - let cache = Cache(feed: feed.map(CodableFeedImage.init), timestamp: timestamp) - let encoded = try encoder.encode(cache) - try encoded.write(to: storeURL) - completion(nil) - } catch { - completion(error) - } - } - - func deleteCachedFeed(completion: @escaping DeletionCompletion) { - guard FileManager.default.fileExists(atPath: storeURL.path) else { - return completion(nil) - } - - do { - try FileManager.default.removeItem(at: storeURL) - completion(nil) - } catch { - completion(error) - } - } -} + class CodableFeedStoreTests: XCTestCase { From 6edb1ca7f52d126ae9653ff17f7e4b01d6886abe Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Mon, 7 Apr 2025 14:23:12 +0200 Subject: [PATCH 096/159] Proved that the `CodableFeedStore` side-effects run serially --- .../Feed Cache/CodableFeedStoreTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 2aac366..da4ecf2 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -118,6 +118,33 @@ class CodableFeedStoreTests: XCTestCase { XCTAssertNotNil(deletionError, "Expect cache deletion to fail") } + func test_storeSideEffects_runSerially() { + let sut = makeSUT() + var completedOperationsInOrder = [XCTestExpectation]() + + let op1 = expectation(description: "Operation 1") + sut.insert(uniqueImageFeed().local, timestamp: Date()) { _ in + completedOperationsInOrder.append(op1) + op1.fulfill() + } + + let op2 = expectation(description: "Operation 2") + sut.deleteCachedFeed { _ in + completedOperationsInOrder.append(op2) + op2.fulfill() + } + + let op3 = expectation(description: "Operation 3") + sut.insert(uniqueImageFeed().local, timestamp: Date()) { _ in + completedOperationsInOrder.append(op3) + op3.fulfill() + } + + waitForExpectations(timeout: 5.0) + + XCTAssertEqual(completedOperationsInOrder, [op1,op2,op3], "Expected operations to run serially but operations finished in the wrong order") + } + // MARK: - Helpers private func makeSUT( storeURL: URL? = nil, From f09d5a18536f19828545569d0a3a70ba377a95c7 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Mon, 7 Apr 2025 14:33:33 +0200 Subject: [PATCH 097/159] Dispatch the `CodableFeedStore` operations in a serial background queue to avoid blocking clients --- .../Feed Cache/CodableFeedStore.swift | 64 +++++++++++-------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CodableFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CodableFeedStore.swift index ca0a30d..b5a3fcf 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/CodableFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/CodableFeedStore.swift @@ -40,47 +40,57 @@ public class CodableFeedStore: FeedStore { } private let storeURL: URL + private let queue = DispatchQueue( + label: "\(CodableFeedStore.self)-queue", + qos: .userInitiated + ) public init(storeURL: URL) { self.storeURL = storeURL } public func retrieve(completion: @escaping RetrievalCompletion) { - guard let data = try? Data(contentsOf: storeURL) else { - return completion(.empty) - } - - do { - let decoder = JSONDecoder() - let cache = try decoder.decode(Cache.self, from: data) - completion(.found(feed: cache.localFeed, timestamp: cache.timestamp)) - } catch { - completion(.failure(error)) + queue.async { [storeURL] in + guard let data = try? Data(contentsOf: storeURL) else { + return completion(.empty) + } + + do { + let decoder = JSONDecoder() + let cache = try decoder.decode(Cache.self, from: data) + completion(.found(feed: cache.localFeed, timestamp: cache.timestamp)) + } catch { + completion(.failure(error)) + } } } public func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { - do { - let encoder = JSONEncoder() - let cache = Cache(feed: feed.map(CodableFeedImage.init), timestamp: timestamp) - let encoded = try encoder.encode(cache) - try encoded.write(to: storeURL) - completion(nil) - } catch { - completion(error) + queue.async { [storeURL] in + do { + let encoder = JSONEncoder() + let cache = Cache(feed: feed.map(CodableFeedImage.init), timestamp: timestamp) + let encoded = try encoder.encode(cache) + try encoded.write(to: storeURL) + completion(nil) + } catch { + completion(error) + } } } public func deleteCachedFeed(completion: @escaping DeletionCompletion) { - guard FileManager.default.fileExists(atPath: storeURL.path) else { - return completion(nil) - } - - do { - try FileManager.default.removeItem(at: storeURL) - completion(nil) - } catch { - completion(error) + queue.async { [storeURL] in + guard FileManager.default.fileExists(atPath: storeURL.path) else { + return completion(nil) + } + + do { + try FileManager.default.removeItem(at: storeURL) + completion(nil) + } catch { + completion(error) + } } } } From 278dd4b196e7ec547a63789fd68d6ca60a67eca3 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Mon, 7 Apr 2025 14:37:51 +0200 Subject: [PATCH 098/159] Make the `CodableFeedStore` queue concurrent to allow multiple `retrieve` to be processed in parallel (since it has no side-effecs) and use `barriers` when performing side-effects to guarantee data consistency and avoid race conditions --- .../EssentialFeed/Feed Cache/CodableFeedStore.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CodableFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CodableFeedStore.swift index b5a3fcf..d939603 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/CodableFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/CodableFeedStore.swift @@ -42,7 +42,8 @@ public class CodableFeedStore: FeedStore { private let storeURL: URL private let queue = DispatchQueue( label: "\(CodableFeedStore.self)-queue", - qos: .userInitiated + qos: .userInitiated, + attributes: .concurrent ) public init(storeURL: URL) { @@ -66,7 +67,7 @@ public class CodableFeedStore: FeedStore { } public func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { - queue.async { [storeURL] in + queue.async(flags: .barrier) { [storeURL] in do { let encoder = JSONEncoder() let cache = Cache(feed: feed.map(CodableFeedImage.init), timestamp: timestamp) @@ -80,7 +81,7 @@ public class CodableFeedStore: FeedStore { } public func deleteCachedFeed(completion: @escaping DeletionCompletion) { - queue.async { [storeURL] in + queue.async(flags: .barrier) { [storeURL] in guard FileManager.default.fileExists(atPath: storeURL.path) else { return completion(nil) } From f08fc5a7b141faabf85fa65436bf57d0f1dfe424 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Mon, 7 Apr 2025 14:41:00 +0200 Subject: [PATCH 099/159] Add comments to document that completion handlers in any `FeedStore` implementation can be invoked in any thread. Clients are responsbile to dispatch to appropiate threads, if needed --- EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift index 8810996..26b34a8 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -18,8 +18,16 @@ public protocol FeedStore { typealias InsertionCompletion = (Error?) -> Void typealias RetrievalCompletion = (RetrieveCachedFeedResult) -> Void + /// The completion handler can be invoked in any thread. + /// Clients are responsible to dispatch to appropiate threads, if needed. func deleteCachedFeed(completion: @escaping DeletionCompletion) + + /// The completion handler can be invoked in any thread. + /// Clients are responsible to dispatch to appropiate threads, if needed. func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) + + /// The completion handler can be invoked in any thread. + /// Clients are responsible to dispatch to appropiate threads, if needed. func retrieve(completion: @escaping RetrievalCompletion) } From 2238e1a2100131f2c228ffcd2358a970470bfee0 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Mon, 7 Apr 2025 14:42:08 +0200 Subject: [PATCH 100/159] Add comment to document that the completion handler in any `HTTPClient` implementation can be invoked in any thread. Clients are responsible to dispatch to appropiate threads, if needed. --- EssentialFeed/EssentialFeed/FeedApi/HTTPClient.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/EssentialFeed/EssentialFeed/FeedApi/HTTPClient.swift b/EssentialFeed/EssentialFeed/FeedApi/HTTPClient.swift index dc7f988..aa292c8 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/HTTPClient.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/HTTPClient.swift @@ -12,5 +12,8 @@ public enum HTTPClientResult { } public protocol HTTPClient { + + /// The completion handler can be invoked in any thread. + /// Clients are responsible to dispatch to appropiate threads, if needed. func get(from url: URL, completion: @escaping (HTTPClientResult) -> Void) } From 15a0438a146ad38ac6fbfa55bff1e5d15b730606 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Mon, 7 Apr 2025 14:53:40 +0200 Subject: [PATCH 101/159] Break down `CodableFeedStore`tests to guarantee there's only one assertion per test. The goal is clarify the behavior under test in small units, so we can extract the behaviour tests into reusable specs. --- .../Feed Cache/CodableFeedStoreTests.swift | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index da4ecf2..e458fd8 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -8,7 +8,34 @@ import XCTest import EssentialFeed +protocol FeedStoreSpecs { + func test_retrieve_deliversEmptyOnEmptyCache() + func test_retrieve_hasNoSideEffectsOnEmptyCache() + func test_retrieve_deliversFoundValuesOnNonEmptyCache() + func test_retrieve_hasNoSideEffectsOnNonEmptyCache() + + func test_insert_overridesPreviouslyInsertedCacheValues() + + func test_delete_hasNoSideEffectsOnEmptyCache() + func test_delete_emptiesPreviouslyInsertedCache() + + func test_storeSideEffects_runSerially() +} + +protocol FailableRetrieveFeedStoreSpecs { + func test_retrieve_deliversFailureOnRetrievalError() + func test_retrieve_hasNoSideEffectsOnFailure() +} +protocol FailableInsertFeedStoreSpecs { + func test_insert_deliversErrorOnInsertionError() + func test_insert_hasNoSideEffectsOnInsertionError() +} + +protocol FailableDeleteFeedStoreSpecs { + func test_delete_deliversErrorOnDeletionError() + func test_delete_hasNoSideEffectsOnDeletionError() +} class CodableFeedStoreTests: XCTestCase { @@ -88,7 +115,17 @@ class CodableFeedStoreTests: XCTestCase { let feed = uniqueImageFeed().local let timestamp = Date() let insertionError = insert((feed, timestamp), to: sut) - XCTAssertNotNil(insertionError, "Expected cache insertion to fail ith an error") + XCTAssertNotNil(insertionError, "Expected cache insertion to fail with an error") + } + + func test_insert_hasNoSideEffectsOnInsertionError() { + let invalidStoreURL = URL(string: "invalid://store-url") + let sut = makeSUT(storeURL: invalidStoreURL) + let feed = uniqueImageFeed().local + let timestamp = Date() + + insert((feed, timestamp), to: sut) + expect(sut, toRetrieve: .empty) } func test_delete_hasNoSideEffectsOnEmptyCache() { @@ -118,6 +155,14 @@ class CodableFeedStoreTests: XCTestCase { XCTAssertNotNil(deletionError, "Expect cache deletion to fail") } + func test_delete_hasNoSideEffectsOnDeletionError() { + let noDeletePermissionURL = systemMaskCachesDirectory() + let sut = makeSUT(storeURL: noDeletePermissionURL) + + deleteCache(from: sut) + expect(sut, toRetrieve: .empty) + } + func test_storeSideEffects_runSerially() { let sut = makeSUT() var completedOperationsInOrder = [XCTestExpectation]() From 4ab67376303aac73a3063c1b3366caaeb216d988 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Mon, 7 Apr 2025 14:56:36 +0200 Subject: [PATCH 102/159] Create `FeedStoreSpecs`that must be implemented by any `FeedStore`test implementation to guarantee it meets spec. --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 ++ .../Feed Cache/CodableFeedStoreTests.swift | 31 +--------------- .../Feed Cache/FeedStoreSpecs.swift | 37 +++++++++++++++++++ 3 files changed, 42 insertions(+), 30 deletions(-) create mode 100644 EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index a7f353a..e27d48a 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */; }; 407F4FAF2D9FFF680070F56E /* CodableFeedStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */; }; 407F4FB22DA022FF0070F56E /* CodableFeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FB02DA022C00070F56E /* CodableFeedStore.swift */; }; + 407F4FCA2DA402D80070F56E /* FeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */; }; 40B002492CF9E9DB0058D3E0 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; @@ -53,6 +54,7 @@ 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCachePolicy.swift; sourceTree = ""; }; 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableFeedStoreTests.swift; sourceTree = ""; }; 407F4FB02DA022C00070F56E /* CodableFeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableFeedStore.swift; sourceTree = ""; }; + 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStoreSpecs.swift; sourceTree = ""; }; 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; @@ -173,6 +175,7 @@ isa = PBXGroup; children = ( 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */, + 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */, 40B9754C2D9EC147009652B5 /* Helpers */, 40B9754A2D9EC05C009652B5 /* LoadFeedFromCacheUseCaseTests.swift */, 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */, @@ -370,6 +373,7 @@ 40B9754E2D9EC15A009652B5 /* FeedStoreSpy.swift in Sources */, 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */, 40B9754B2D9EC061009652B5 /* LoadFeedFromCacheUseCaseTests.swift in Sources */, + 407F4FCA2DA402D80070F56E /* FeedStoreSpecs.swift in Sources */, 407F4FA72D9FDB810070F56E /* FeedCacheTestHelpers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index e458fd8..c032b4c 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -8,36 +8,7 @@ import XCTest import EssentialFeed -protocol FeedStoreSpecs { - func test_retrieve_deliversEmptyOnEmptyCache() - func test_retrieve_hasNoSideEffectsOnEmptyCache() - func test_retrieve_deliversFoundValuesOnNonEmptyCache() - func test_retrieve_hasNoSideEffectsOnNonEmptyCache() - - func test_insert_overridesPreviouslyInsertedCacheValues() - - func test_delete_hasNoSideEffectsOnEmptyCache() - func test_delete_emptiesPreviouslyInsertedCache() - - func test_storeSideEffects_runSerially() -} - -protocol FailableRetrieveFeedStoreSpecs { - func test_retrieve_deliversFailureOnRetrievalError() - func test_retrieve_hasNoSideEffectsOnFailure() -} - -protocol FailableInsertFeedStoreSpecs { - func test_insert_deliversErrorOnInsertionError() - func test_insert_hasNoSideEffectsOnInsertionError() -} - -protocol FailableDeleteFeedStoreSpecs { - func test_delete_deliversErrorOnDeletionError() - func test_delete_hasNoSideEffectsOnDeletionError() -} - -class CodableFeedStoreTests: XCTestCase { +class CodableFeedStoreTests: XCTestCase, FailableFeedStoreSpecs { override func setUp() { super.setUp() diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift new file mode 100644 index 0000000..57d2192 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift @@ -0,0 +1,37 @@ +// +// FeedStoreSpecs.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 7/4/25. +// + +protocol FeedStoreSpecs { + func test_retrieve_deliversEmptyOnEmptyCache() + func test_retrieve_hasNoSideEffectsOnEmptyCache() + func test_retrieve_deliversFoundValuesOnNonEmptyCache() + func test_retrieve_hasNoSideEffectsOnNonEmptyCache() + + func test_insert_overridesPreviouslyInsertedCacheValues() + + func test_delete_hasNoSideEffectsOnEmptyCache() + func test_delete_emptiesPreviouslyInsertedCache() + + func test_storeSideEffects_runSerially() +} + +protocol FailableRetrieveFeedStoreSpecs: FeedStoreSpecs { + func test_retrieve_deliversFailureOnRetrievalError() + func test_retrieve_hasNoSideEffectsOnFailure() +} + +protocol FailableInsertFeedStoreSpecs: FeedStoreSpecs { + func test_insert_deliversErrorOnInsertionError() + func test_insert_hasNoSideEffectsOnInsertionError() +} + +protocol FailableDeleteFeedStoreSpecs: FeedStoreSpecs { + func test_delete_deliversErrorOnDeletionError() + func test_delete_hasNoSideEffectsOnDeletionError() +} + +typealias FailableFeedStoreSpecs = FailableRetrieveFeedStoreSpecs & FailableInsertFeedStoreSpecs & FailableDeleteFeedStoreSpecs From 3ae4780009c871c9489362abc634092ee1f7e377 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Mon, 7 Apr 2025 14:59:38 +0200 Subject: [PATCH 103/159] Extract reusable `FeedStoreSpecs`helper methods into a shared scope so it can be used by any other `FeedStore` implementation tests --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 ++ .../Feed Cache/CodableFeedStoreTests.swift | 59 ---------------- .../XCTestCase+FeedStoreSpecs.swift | 69 +++++++++++++++++++ 3 files changed, 73 insertions(+), 59 deletions(-) create mode 100644 EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index e27d48a..fbad37f 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 407F4FAF2D9FFF680070F56E /* CodableFeedStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */; }; 407F4FB22DA022FF0070F56E /* CodableFeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FB02DA022C00070F56E /* CodableFeedStore.swift */; }; 407F4FCA2DA402D80070F56E /* FeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */; }; + 407F4FCC2DA403330070F56E /* XCTestCase+FeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FCB2DA403260070F56E /* XCTestCase+FeedStoreSpecs.swift */; }; 40B002492CF9E9DB0058D3E0 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; @@ -55,6 +56,7 @@ 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableFeedStoreTests.swift; sourceTree = ""; }; 407F4FB02DA022C00070F56E /* CodableFeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableFeedStore.swift; sourceTree = ""; }; 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStoreSpecs.swift; sourceTree = ""; }; + 407F4FCB2DA403260070F56E /* XCTestCase+FeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+FeedStoreSpecs.swift"; sourceTree = ""; }; 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; @@ -175,6 +177,7 @@ isa = PBXGroup; children = ( 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */, + 407F4FCB2DA403260070F56E /* XCTestCase+FeedStoreSpecs.swift */, 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */, 40B9754C2D9EC147009652B5 /* Helpers */, 40B9754A2D9EC05C009652B5 /* LoadFeedFromCacheUseCaseTests.swift */, @@ -375,6 +378,7 @@ 40B9754B2D9EC061009652B5 /* LoadFeedFromCacheUseCaseTests.swift in Sources */, 407F4FCA2DA402D80070F56E /* FeedStoreSpecs.swift in Sources */, 407F4FA72D9FDB810070F56E /* FeedCacheTestHelpers.swift in Sources */, + 407F4FCC2DA403330070F56E /* XCTestCase+FeedStoreSpecs.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index c032b4c..35ae017 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -172,65 +172,6 @@ class CodableFeedStoreTests: XCTestCase, FailableFeedStoreSpecs { return sut } - @discardableResult - private func insert(_ cache: (feed: [LocalFeedImage], timestamp: Date), to sut: FeedStore) -> Error? { - let exp = expectation(description: "Wait for cache insertion") - var insertionError: Error? - sut.insert(cache.feed, timestamp: cache.timestamp) { error in - insertionError = error - exp.fulfill() - } - - wait(for: [exp], timeout: 1.0) - return insertionError - } - - @discardableResult - private func deleteCache(from sut: FeedStore) -> Error? { - let exp = expectation(description: "Wait for cache deletion") - var receivedError: Error? - sut.deleteCachedFeed { - exp.fulfill() - receivedError = $0 - } - - wait(for: [exp], timeout: 1.0) - return receivedError - } - - private func expect( - _ sut: FeedStore, - toRetrieve expectedResult: RetrieveCachedFeedResult, - file: StaticString = #file, - line: UInt = #line - ) { - let exp = expectation(description: "Wait for cache retrieval") - - sut.retrieve { retrievedResult in - exp.fulfill() - switch (expectedResult, retrievedResult) { - case (.empty, .empty), (.failure, .failure): - break - case let (.found(expectedFeed, expectedTimestmap), .found(retrievedFeed, retrievedTimestamp)): - XCTAssertEqual(expectedFeed, retrievedFeed, file: file, line: line) - XCTAssertEqual(expectedTimestmap, retrievedTimestamp, file: file, line: line) - default: - XCTFail("Expected to retrieve \(expectedResult), but got \(retrievedResult)", file: file, line: line) - } - } - wait(for: [exp], timeout: 1.0) - } - - private func expect( - _ sut: FeedStore, - toRetrieveTwice expectedResult: RetrieveCachedFeedResult, - file: StaticString = #file, - line: UInt = #line - ) { - expect(sut, toRetrieve: expectedResult, file: file, line: line) - expect(sut, toRetrieve: expectedResult, file: file, line: line) - } - private func systemMaskCachesDirectory() -> URL { FileManager.default.urls(for: .cachesDirectory, in: .systemDomainMask).first! } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift new file mode 100644 index 0000000..8eb802b --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift @@ -0,0 +1,69 @@ +// +// XCTestCase+FeedStoreSpecs.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 7/4/25. +// +import XCTest +import EssentialFeed + +extension FeedStoreSpecs where Self: XCTestCase { + @discardableResult + func insert(_ cache: (feed: [LocalFeedImage], timestamp: Date), to sut: FeedStore) -> Error? { + let exp = expectation(description: "Wait for cache insertion") + var insertionError: Error? + sut.insert(cache.feed, timestamp: cache.timestamp) { error in + insertionError = error + exp.fulfill() + } + + wait(for: [exp], timeout: 1.0) + return insertionError + } + + @discardableResult + func deleteCache(from sut: FeedStore) -> Error? { + let exp = expectation(description: "Wait for cache deletion") + var receivedError: Error? + sut.deleteCachedFeed { + exp.fulfill() + receivedError = $0 + } + + wait(for: [exp], timeout: 1.0) + return receivedError + } + + func expect( + _ sut: FeedStore, + toRetrieve expectedResult: RetrieveCachedFeedResult, + file: StaticString = #file, + line: UInt = #line + ) { + let exp = expectation(description: "Wait for cache retrieval") + + sut.retrieve { retrievedResult in + exp.fulfill() + switch (expectedResult, retrievedResult) { + case (.empty, .empty), (.failure, .failure): + break + case let (.found(expectedFeed, expectedTimestmap), .found(retrievedFeed, retrievedTimestamp)): + XCTAssertEqual(expectedFeed, retrievedFeed, file: file, line: line) + XCTAssertEqual(expectedTimestmap, retrievedTimestamp, file: file, line: line) + default: + XCTFail("Expected to retrieve \(expectedResult), but got \(retrievedResult)", file: file, line: line) + } + } + wait(for: [exp], timeout: 1.0) + } + + func expect( + _ sut: FeedStore, + toRetrieveTwice expectedResult: RetrieveCachedFeedResult, + file: StaticString = #file, + line: UInt = #line + ) { + expect(sut, toRetrieve: expectedResult, file: file, line: line) + expect(sut, toRetrieve: expectedResult, file: file, line: line) + } +} From 1f29743fe0a25488e8271efbc68d7a208f2aed0b Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Tue, 8 Apr 2025 10:27:00 +0200 Subject: [PATCH 104/159] Move failable protocol methods to their own extension to have a better view of which method belongs to which protocol --- .../Feed Cache/CodableFeedStoreTests.swift | 117 ++++++++++-------- .../Feed Cache/FeedStoreSpecs.swift | 2 - 2 files changed, 62 insertions(+), 57 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 35ae017..0d88ec9 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -8,7 +8,7 @@ import XCTest import EssentialFeed -class CodableFeedStoreTests: XCTestCase, FailableFeedStoreSpecs { +class CodableFeedStoreTests: XCTestCase, FeedStoreSpecs { override func setUp() { super.setUp() @@ -48,24 +48,6 @@ class CodableFeedStoreTests: XCTestCase, FailableFeedStoreSpecs { expect(sut, toRetrieveTwice: .found(feed: feed, timestamp: timestamp)) } - func test_retrieve_deliversFailureOnRetrievalError() { - let storeURL = testSpecificStoreURL() - let sut = makeSUT(storeURL: storeURL) - - try! "invalid data".write(to: storeURL, atomically: false, encoding: .utf8) - - expect(sut, toRetrieve: .failure(anyNSError())) - } - - func test_retrieve_hasNoSideEffectsOnFailure() { - let storeURL = testSpecificStoreURL() - let sut = makeSUT(storeURL: storeURL) - - try! "invalid data".write(to: storeURL, atomically: false, encoding: .utf8) - - expect(sut, toRetrieveTwice: .failure(anyNSError())) - } - func test_insert_overridesPreviouslyInsertedCacheValues() { let sut = makeSUT() @@ -80,25 +62,6 @@ class CodableFeedStoreTests: XCTestCase, FailableFeedStoreSpecs { expect(sut, toRetrieve: .found(feed: latestFeed, timestamp: latesTimestamp)) } - func test_insert_deliversErrorOnInsertionError() { - let invalidStoreURL = URL(string: "invalid://store-url") - let sut = makeSUT(storeURL: invalidStoreURL) - let feed = uniqueImageFeed().local - let timestamp = Date() - let insertionError = insert((feed, timestamp), to: sut) - XCTAssertNotNil(insertionError, "Expected cache insertion to fail with an error") - } - - func test_insert_hasNoSideEffectsOnInsertionError() { - let invalidStoreURL = URL(string: "invalid://store-url") - let sut = makeSUT(storeURL: invalidStoreURL) - let feed = uniqueImageFeed().local - let timestamp = Date() - - insert((feed, timestamp), to: sut) - expect(sut, toRetrieve: .empty) - } - func test_delete_hasNoSideEffectsOnEmptyCache() { let sut = makeSUT() let deletionError = deleteCache(from: sut) @@ -117,23 +80,6 @@ class CodableFeedStoreTests: XCTestCase, FailableFeedStoreSpecs { expect(sut, toRetrieve: .empty) } - func test_delete_deliversErrorOnDeletionError() { - let noDeletePermissionURL = systemMaskCachesDirectory() - let sut = makeSUT(storeURL: noDeletePermissionURL) - - let deletionError = deleteCache(from: sut) - - XCTAssertNotNil(deletionError, "Expect cache deletion to fail") - } - - func test_delete_hasNoSideEffectsOnDeletionError() { - let noDeletePermissionURL = systemMaskCachesDirectory() - let sut = makeSUT(storeURL: noDeletePermissionURL) - - deleteCache(from: sut) - expect(sut, toRetrieve: .empty) - } - func test_storeSideEffects_runSerially() { let sut = makeSUT() var completedOperationsInOrder = [XCTestExpectation]() @@ -192,3 +138,64 @@ class CodableFeedStoreTests: XCTestCase, FailableFeedStoreSpecs { try? FileManager.default.removeItem(at: testSpecificStoreURL()) } } + +extension CodableFeedStoreTests: FailableRetrieveFeedStoreSpecs { + func test_retrieve_deliversFailureOnRetrievalError() { + let storeURL = testSpecificStoreURL() + let sut = makeSUT(storeURL: storeURL) + + try! "invalid data".write(to: storeURL, atomically: false, encoding: .utf8) + + expect(sut, toRetrieve: .failure(anyNSError())) + } + + func test_retrieve_hasNoSideEffectsOnFailure() { + let storeURL = testSpecificStoreURL() + let sut = makeSUT(storeURL: storeURL) + + try! "invalid data".write(to: storeURL, atomically: false, encoding: .utf8) + + expect(sut, toRetrieveTwice: .failure(anyNSError())) + } +} + +extension CodableFeedStoreTests: FailableInsertFeedStoreSpecs { + func test_insert_deliversErrorOnInsertionError() { + let invalidStoreURL = URL(string: "invalid://store-url") + let sut = makeSUT(storeURL: invalidStoreURL) + let feed = uniqueImageFeed().local + let timestamp = Date() + let insertionError = insert((feed, timestamp), to: sut) + XCTAssertNotNil(insertionError, "Expected cache insertion to fail with an error") + } + + func test_insert_hasNoSideEffectsOnInsertionError() { + let invalidStoreURL = URL(string: "invalid://store-url") + let sut = makeSUT(storeURL: invalidStoreURL) + let feed = uniqueImageFeed().local + let timestamp = Date() + + insert((feed, timestamp), to: sut) + expect(sut, toRetrieve: .empty) + } +} + + +extension CodableFeedStoreTests: FailableDeleteFeedStoreSpecs { + func test_delete_deliversErrorOnDeletionError() { + let noDeletePermissionURL = systemMaskCachesDirectory() + let sut = makeSUT(storeURL: noDeletePermissionURL) + + let deletionError = deleteCache(from: sut) + + XCTAssertNotNil(deletionError, "Expect cache deletion to fail") + } + + func test_delete_hasNoSideEffectsOnDeletionError() { + let noDeletePermissionURL = systemMaskCachesDirectory() + let sut = makeSUT(storeURL: noDeletePermissionURL) + + deleteCache(from: sut) + expect(sut, toRetrieve: .empty) + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift index 57d2192..78cea96 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift @@ -33,5 +33,3 @@ protocol FailableDeleteFeedStoreSpecs: FeedStoreSpecs { func test_delete_deliversErrorOnDeletionError() func test_delete_hasNoSideEffectsOnDeletionError() } - -typealias FailableFeedStoreSpecs = FailableRetrieveFeedStoreSpecs & FailableInsertFeedStoreSpecs & FailableDeleteFeedStoreSpecs From 2a76201b87887618480defa1fed80756a922081f Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Tue, 8 Apr 2025 10:30:11 +0200 Subject: [PATCH 105/159] Extract common specs into asserts so we can reuse implementation when testing differents persistency solutions (frameworks) --- .../Feed Cache/CodableFeedStoreTests.swift | 62 ++-------- .../XCTestCase+FeedStoreSpecs.swift | 110 ++++++++++++++++++ 2 files changed, 118 insertions(+), 54 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 0d88ec9..d9188d1 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -22,89 +22,43 @@ class CodableFeedStoreTests: XCTestCase, FeedStoreSpecs { func test_retrieve_deliversEmptyOnEmptyCache() { let sut = makeSUT() - expect(sut, toRetrieve: .empty) + assertThatStoreSideEffectsRunSerially(on: sut) } func test_retrieve_hasNoSideEffectsOnEmptyCache() { let sut = makeSUT() - expect(sut, toRetrieveTwice: .empty) + assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) } func test_retrieve_deliversFoundValuesOnNonEmptyCache() { let sut = makeSUT() - let feed = uniqueImageFeed().local - let timestamp = Date() - - insert((feed, timestamp), to: sut) - expect(sut, toRetrieve: .found(feed: feed, timestamp: timestamp)) + assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) } func test_retrieve_hasNoSideEffectsOnNonEmptyCache() { let sut = makeSUT() - let feed = uniqueImageFeed().local - let timestamp = Date() - - insert((feed, timestamp), to: sut) - expect(sut, toRetrieveTwice: .found(feed: feed, timestamp: timestamp)) + assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) } func test_insert_overridesPreviouslyInsertedCacheValues() { let sut = makeSUT() - let firstInsertionError = insert((uniqueImageFeed().local, Date()), to: sut) - XCTAssertNil(firstInsertionError, "Expected to insert cache succesfully") - - let latestFeed = uniqueImageFeed().local - let latesTimestamp = Date() - let latestInsertionError = insert((latestFeed, latesTimestamp), to: sut) - - XCTAssertNil(latestInsertionError, "Expected to override cache succesfully") - expect(sut, toRetrieve: .found(feed: latestFeed, timestamp: latesTimestamp)) + assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) } func test_delete_hasNoSideEffectsOnEmptyCache() { let sut = makeSUT() - let deletionError = deleteCache(from: sut) - - XCTAssertNil(deletionError, "Expected empty cache deletion to succeed") - expect(sut, toRetrieve: .empty) + assertThatDeleteHasNoSideEffectOnEmptyCache(on: sut) } func test_delete_emptiesPreviouslyInsertedCache() { let sut = makeSUT() - insert((uniqueImageFeed().local, Date()), to: sut) - - let deletionError = deleteCache(from: sut) - - XCTAssertNil(deletionError, "Expected non-empty cache deletion to succeed") - expect(sut, toRetrieve: .empty) + assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) } func test_storeSideEffects_runSerially() { let sut = makeSUT() - var completedOperationsInOrder = [XCTestExpectation]() - - let op1 = expectation(description: "Operation 1") - sut.insert(uniqueImageFeed().local, timestamp: Date()) { _ in - completedOperationsInOrder.append(op1) - op1.fulfill() - } - - let op2 = expectation(description: "Operation 2") - sut.deleteCachedFeed { _ in - completedOperationsInOrder.append(op2) - op2.fulfill() - } - - let op3 = expectation(description: "Operation 3") - sut.insert(uniqueImageFeed().local, timestamp: Date()) { _ in - completedOperationsInOrder.append(op3) - op3.fulfill() - } - - waitForExpectations(timeout: 5.0) - - XCTAssertEqual(completedOperationsInOrder, [op1,op2,op3], "Expected operations to run serially but operations finished in the wrong order") + assertThatStoreSideEffectsRunSerially(on: sut) } // MARK: - Helpers diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift index 8eb802b..e5fa8c9 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift @@ -66,4 +66,114 @@ extension FeedStoreSpecs where Self: XCTestCase { expect(sut, toRetrieve: expectedResult, file: file, line: line) expect(sut, toRetrieve: expectedResult, file: file, line: line) } + + func assertThatRetrieveDeliversEmptyOnEmptyCache( + on sut: FeedStore, + file: StaticString = #file, + line: UInt = #line + ) { + expect(sut, toRetrieve: .empty) + } + + func assertThatRetrieveHasNoSideEffectsOnEmptyCache( + on sut: FeedStore, + file: StaticString = #file, + line: UInt = #line + ) { + expect(sut, toRetrieveTwice: .empty) + } + + func assertThatRetrieveDeliversFoundValuesOnNonEmptyCache( + on sut: FeedStore, + file: StaticString = #file, + line: UInt = #line + ) { + let feed = uniqueImageFeed().local + let timestamp = Date() + + insert((feed, timestamp), to: sut) + expect(sut, toRetrieve: .found(feed: feed, timestamp: timestamp)) + } + + func assertThatRetrieveHasNoSideEffectsOnNonEmptyCache( + on sut: FeedStore, + file: StaticString = #file, + line: UInt = #line + ) { + let feed = uniqueImageFeed().local + let timestamp = Date() + + insert((feed, timestamp), to: sut) + expect(sut, toRetrieveTwice: .found(feed: feed, timestamp: timestamp)) + } + + func assertThatInsertOverridesPreviouslyInsertedCacheValues( + on sut: FeedStore, + file: StaticString = #file, + line: UInt = #line + ) { + let firstInsertionError = insert((uniqueImageFeed().local, Date()), to: sut) + XCTAssertNil(firstInsertionError, "Expected to insert cache succesfully") + + let latestFeed = uniqueImageFeed().local + let latesTimestamp = Date() + let latestInsertionError = insert((latestFeed, latesTimestamp), to: sut) + + XCTAssertNil(latestInsertionError, "Expected to override cache succesfully") + expect(sut, toRetrieve: .found(feed: latestFeed, timestamp: latesTimestamp)) + } + + func assertThatDeleteHasNoSideEffectOnEmptyCache( + on sut: FeedStore, + file: StaticString = #file, + line: UInt = #line + ) { + let deletionError = deleteCache(from: sut) + + XCTAssertNil(deletionError, "Expected empty cache deletion to succeed") + expect(sut, toRetrieve: .empty) + } + + func assertThatDeleteEmptiesPreviouslyInsertedCache( + on sut: FeedStore, + file: StaticString = #file, + line: UInt = #line + ) { + insert((uniqueImageFeed().local, Date()), to: sut) + + let deletionError = deleteCache(from: sut) + + XCTAssertNil(deletionError, "Expected non-empty cache deletion to succeed") + expect(sut, toRetrieve: .empty) + } + + func assertThatStoreSideEffectsRunSerially( + on sut: FeedStore, + file: StaticString = #file, + line: UInt = #line + ) { + var completedOperationsInOrder = [XCTestExpectation]() + + let op1 = expectation(description: "Operation 1") + sut.insert(uniqueImageFeed().local, timestamp: Date()) { _ in + completedOperationsInOrder.append(op1) + op1.fulfill() + } + + let op2 = expectation(description: "Operation 2") + sut.deleteCachedFeed { _ in + completedOperationsInOrder.append(op2) + op2.fulfill() + } + + let op3 = expectation(description: "Operation 3") + sut.insert(uniqueImageFeed().local, timestamp: Date()) { _ in + completedOperationsInOrder.append(op3) + op3.fulfill() + } + + waitForExpectations(timeout: 5.0) + + XCTAssertEqual(completedOperationsInOrder, [op1,op2,op3], "Expected operations to run serially but operations finished in the wrong order") + } } From 8c0ac1a8101b54df96a6ae476f323c27208d7e47 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Tue, 8 Apr 2025 12:24:02 +0200 Subject: [PATCH 106/159] Add empty core data feed store specs --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 +++ .../Feed Cache/CoreDataFeedStoreSpecs.swift | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index fbad37f..ef856a5 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 407F4FB22DA022FF0070F56E /* CodableFeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FB02DA022C00070F56E /* CodableFeedStore.swift */; }; 407F4FCA2DA402D80070F56E /* FeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */; }; 407F4FCC2DA403330070F56E /* XCTestCase+FeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FCB2DA403260070F56E /* XCTestCase+FeedStoreSpecs.swift */; }; + 407F4FD12DA530810070F56E /* CoreDataFeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FD02DA5307B0070F56E /* CoreDataFeedStoreSpecs.swift */; }; 40B002492CF9E9DB0058D3E0 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; @@ -57,6 +58,7 @@ 407F4FB02DA022C00070F56E /* CodableFeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableFeedStore.swift; sourceTree = ""; }; 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStoreSpecs.swift; sourceTree = ""; }; 407F4FCB2DA403260070F56E /* XCTestCase+FeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+FeedStoreSpecs.swift"; sourceTree = ""; }; + 407F4FD02DA5307B0070F56E /* CoreDataFeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataFeedStoreSpecs.swift; sourceTree = ""; }; 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; @@ -177,6 +179,7 @@ isa = PBXGroup; children = ( 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */, + 407F4FD02DA5307B0070F56E /* CoreDataFeedStoreSpecs.swift */, 407F4FCB2DA403260070F56E /* XCTestCase+FeedStoreSpecs.swift */, 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */, 40B9754C2D9EC147009652B5 /* Helpers */, @@ -374,6 +377,7 @@ 407F4FAF2D9FFF680070F56E /* CodableFeedStoreTests.swift in Sources */, 407F4FA52D9FD7F90070F56E /* ValidateFeedCacheUseCaseTests.swift in Sources */, 40B9754E2D9EC15A009652B5 /* FeedStoreSpy.swift in Sources */, + 407F4FD12DA530810070F56E /* CoreDataFeedStoreSpecs.swift in Sources */, 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */, 40B9754B2D9EC061009652B5 /* LoadFeedFromCacheUseCaseTests.swift in Sources */, 407F4FCA2DA402D80070F56E /* FeedStoreSpecs.swift in Sources */, diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift new file mode 100644 index 0000000..87a66a6 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -0,0 +1,34 @@ +// +// CoreDataFeedStoreSpecs.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 8/4/25. +// + +import XCTest + +class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { + func test_retrieve_deliversEmptyOnEmptyCache() { + } + + func test_retrieve_hasNoSideEffectsOnEmptyCache() { + } + + func test_retrieve_deliversFoundValuesOnNonEmptyCache() { + } + + func test_retrieve_hasNoSideEffectsOnNonEmptyCache() { + } + + func test_insert_overridesPreviouslyInsertedCacheValues() { + } + + func test_delete_hasNoSideEffectsOnEmptyCache() { + } + + func test_delete_emptiesPreviouslyInsertedCache() { + } + + func test_storeSideEffects_runSerially() { + } +} From 870e879434bc6fb617f1dfc48fce5af93edae79d Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Tue, 8 Apr 2025 12:30:14 +0200 Subject: [PATCH 107/159] Retrieve command delivers empty on empty cache --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 +++ .../Feed Cache/CoreDataStore.swift | 26 +++++++++++++++++++ .../Feed Cache/CoreDataFeedStoreSpecs.swift | 10 +++++++ 3 files changed, 40 insertions(+) create mode 100644 EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index ef856a5..b91a947 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 407F4FCA2DA402D80070F56E /* FeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */; }; 407F4FCC2DA403330070F56E /* XCTestCase+FeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FCB2DA403260070F56E /* XCTestCase+FeedStoreSpecs.swift */; }; 407F4FD12DA530810070F56E /* CoreDataFeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FD02DA5307B0070F56E /* CoreDataFeedStoreSpecs.swift */; }; + 407F4FD32DA531050070F56E /* CoreDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FD22DA530FF0070F56E /* CoreDataStore.swift */; }; 40B002492CF9E9DB0058D3E0 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; @@ -59,6 +60,7 @@ 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStoreSpecs.swift; sourceTree = ""; }; 407F4FCB2DA403260070F56E /* XCTestCase+FeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+FeedStoreSpecs.swift"; sourceTree = ""; }; 407F4FD02DA5307B0070F56E /* CoreDataFeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataFeedStoreSpecs.swift; sourceTree = ""; }; + 407F4FD22DA530FF0070F56E /* CoreDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStore.swift; sourceTree = ""; }; 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; @@ -193,6 +195,7 @@ 40B975412D9E9FB7009652B5 /* Feed Cache */ = { isa = PBXGroup; children = ( + 407F4FD22DA530FF0070F56E /* CoreDataStore.swift */, 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */, 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */, 40B975442D9EA01D009652B5 /* FeedStore.swift */, @@ -366,6 +369,7 @@ 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */, 40B975452D9EA01D009652B5 /* FeedStore.swift in Sources */, 407F4FB22DA022FF0070F56E /* CodableFeedStore.swift in Sources */, + 407F4FD32DA531050070F56E /* CoreDataStore.swift in Sources */, 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift new file mode 100644 index 0000000..5530f2f --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift @@ -0,0 +1,26 @@ +// +// CoreDataStore.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 8/4/25. +// + +import Foundation + +public final class CoreDataStore: FeedStore { + + public init() {} + + public func retrieve(completion: @escaping RetrievalCompletion) { + completion(.empty) + } + + public func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { + + } + + + public func deleteCachedFeed(completion: @escaping DeletionCompletion) { + + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index 87a66a6..c874f8a 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -6,9 +6,12 @@ // import XCTest +import EssentialFeed class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { func test_retrieve_deliversEmptyOnEmptyCache() { + let sut = makeSUT() + assertThatRetrieveDeliversEmptyOnEmptyCache(on: sut) } func test_retrieve_hasNoSideEffectsOnEmptyCache() { @@ -31,4 +34,11 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { func test_storeSideEffects_runSerially() { } + + // MARK: - Helpers + private func makeSUT(file: StaticString = #file, line: UInt = #line) -> CoreDataStore { + let sut = CoreDataStore() + trackForMemoryLeaks(sut, file: file, line: line) + return sut + } } From 56fa809399d6d8c753c173a175b9dccae5409193 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Tue, 8 Apr 2025 12:30:58 +0200 Subject: [PATCH 108/159] Retrieve command has no side effects on empty cache --- .../EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index c874f8a..6480e45 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -15,6 +15,8 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { } func test_retrieve_hasNoSideEffectsOnEmptyCache() { + let sut = makeSUT() + assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) } func test_retrieve_deliversFoundValuesOnNonEmptyCache() { From dbd48706417c6f9e0de2a43e2b657ad0a7121cbf Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 09:24:05 +0200 Subject: [PATCH 109/159] Add CoreDataFeedStoer data model --- .../EssentialFeed.xcodeproj/project.pbxproj | 17 +++++++++++++++++ .../FeedStore.xcdatamodel/contents | 14 ++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 EssentialFeed/EssentialFeed/Feed Cache/FeedStore.xcdatamodeld/FeedStore.xcdatamodel/contents diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index b91a947..c8d9865 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 080EDEFB21B6DA7E00813479 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 080EDF0C21B6DAE800813479 /* FeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0B21B6DAE800813479 /* FeedImage.swift */; }; 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0D21B6DCB600813479 /* FeedLoader.swift */; }; + 40412A172DA65403004677C4 /* FeedStore.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 40412A152DA65403004677C4 /* FeedStore.xcdatamodeld */; }; 407F4FA52D9FD7F90070F56E /* ValidateFeedCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */; }; 407F4FA72D9FDB810070F56E /* FeedCacheTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */; }; 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */; }; @@ -52,6 +53,7 @@ 080EDF0121B6DA7E00813479 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 080EDF0B21B6DAE800813479 /* FeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImage.swift; sourceTree = ""; }; 080EDF0D21B6DCB600813479 /* FeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoader.swift; sourceTree = ""; }; + 40412A162DA65403004677C4 /* FeedStore.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = FeedStore.xcdatamodel; sourceTree = ""; }; 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateFeedCacheUseCaseTests.swift; sourceTree = ""; }; 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCacheTestHelpers.swift; sourceTree = ""; }; 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCachePolicy.swift; sourceTree = ""; }; @@ -196,6 +198,7 @@ isa = PBXGroup; children = ( 407F4FD22DA530FF0070F56E /* CoreDataStore.swift */, + 40412A152DA65403004677C4 /* FeedStore.xcdatamodeld */, 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */, 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */, 40B975442D9EA01D009652B5 /* FeedStore.swift */, @@ -363,6 +366,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 40412A172DA65403004677C4 /* FeedStore.xcdatamodeld in Sources */, 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */, 080EDF0C21B6DAE800813479 /* FeedImage.swift in Sources */, 40B975492D9EA7A2009652B5 /* LocalFeedImage.swift in Sources */, @@ -727,6 +731,19 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCVersionGroup section */ + 40412A152DA65403004677C4 /* FeedStore.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 40412A162DA65403004677C4 /* FeedStore.xcdatamodel */, + ); + currentVersion = 40412A162DA65403004677C4 /* FeedStore.xcdatamodel */; + path = FeedStore.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ }; rootObject = 080EDEE821B6DA7E00813479 /* Project object */; } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.xcdatamodeld/FeedStore.xcdatamodel/contents b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.xcdatamodeld/FeedStore.xcdatamodel/contents new file mode 100644 index 0000000..7182245 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.xcdatamodeld/FeedStore.xcdatamodel/contents @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file From d065ca97759c67e957cc51ebfd4b0d9761b57c20 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 09:26:25 +0200 Subject: [PATCH 110/159] Add `ManagedCache` and `ManagedFeedImage` model representations --- .../EssentialFeed/Feed Cache/CoreDataStore.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift index 5530f2f..257ff18 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift @@ -5,7 +5,7 @@ // Created by Cristian Felipe Patiño Rojas on 8/4/25. // -import Foundation +import CoreData public final class CoreDataStore: FeedStore { @@ -24,3 +24,16 @@ public final class CoreDataStore: FeedStore { } } + +private class ManagedCache: NSManagedObject { + @NSManaged var timestamp: Date + @NSManaged var feed: NSOrderedSet +} + +private class ManagedFeedImage: NSManagedObject { + @NSManaged var id: UUID + @NSManaged var imageDescription: String? + @NSManaged var location: String? + @NSManaged var url: URL + @NSManaged var cache: ManagedCache +} From 345dbf7b56e3e8beb515711e195856914d9f4b4b Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 09:34:05 +0200 Subject: [PATCH 111/159] Load persistent container upon `CoreDataFeedStoer` initialization --- .../Feed Cache/CoreDataStore.swift | 36 ++++++++++++++++++- .../Feed Cache/CoreDataFeedStoreSpecs.swift | 3 +- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift index 257ff18..93480a7 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift @@ -9,7 +9,10 @@ import CoreData public final class CoreDataStore: FeedStore { - public init() {} + let container: NSPersistentContainer + public init(bundle: Bundle = .main) throws { + container = try NSPersistentContainer.load(modelName: "FeedStore", in: bundle) + } public func retrieve(completion: @escaping RetrievalCompletion) { completion(.empty) @@ -25,6 +28,37 @@ public final class CoreDataStore: FeedStore { } } +private extension NSPersistentContainer { + enum LoadingError: Error { + case modelNotFound + case failedToPersistentStores(Error) + } + + static func load(modelName name: String, in bundle: Bundle) throws -> NSPersistentContainer { + guard let model = NSManagedObjectModel.with(name: name, in: bundle) else { throw LoadingError.modelNotFound } + let container = NSPersistentContainer(name: name, managedObjectModel: model) + var loadError: Error? + container.loadPersistentStores { + loadError = $1 + } + + try loadError.map { + throw LoadingError.failedToPersistentStores($0) + } + + return container + } +} + +private extension NSManagedObjectModel { + static func with(name: String, in bundle: Bundle) -> NSManagedObjectModel? { + return bundle.url(forResource: name, withExtension: "momd") + .flatMap { + NSManagedObjectModel(contentsOf: $0) + } + } +} + private class ManagedCache: NSManagedObject { @NSManaged var timestamp: Date @NSManaged var feed: NSOrderedSet diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index 6480e45..14a02e5 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -39,7 +39,8 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> CoreDataStore { - let sut = CoreDataStore() + let storeBundle = Bundle(for: CoreDataStore.self) + let sut = try! CoreDataStore(bundle: storeBundle) trackForMemoryLeaks(sut, file: file, line: line) return sut } From e9ce2b0f1ad449c0f8a2e855e44931b5554405ce Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 09:35:00 +0200 Subject: [PATCH 112/159] Add private background context to perform store operations --- EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift index 93480a7..e3b0891 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift @@ -10,8 +10,10 @@ import CoreData public final class CoreDataStore: FeedStore { let container: NSPersistentContainer + private let context: NSManagedObjectContext public init(bundle: Bundle = .main) throws { container = try NSPersistentContainer.load(modelName: "FeedStore", in: bundle) + context = container.newBackgroundContext() } public func retrieve(completion: @escaping RetrievalCompletion) { From 49e62b38dca4b3d23a6ebd92dbee928819c3e4b9 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 09:38:29 +0200 Subject: [PATCH 113/159] Mke `storeURL` an explicit dependency so we can inject test-specific URLs (such as `/dev/null`) to avoid sharing state with production (and other tests!) --- .../Feed Cache/CoreDataStore.swift | 19 ++++++++++++++++--- .../Feed Cache/CoreDataFeedStoreSpecs.swift | 6 +++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift index e3b0891..ccfa252 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift @@ -11,8 +11,12 @@ public final class CoreDataStore: FeedStore { let container: NSPersistentContainer private let context: NSManagedObjectContext - public init(bundle: Bundle = .main) throws { - container = try NSPersistentContainer.load(modelName: "FeedStore", in: bundle) + public init(storeURL: URL, bundle: Bundle = .main) throws { + container = try NSPersistentContainer.load( + modelName: "FeedStore", + url: storeURL, + in: bundle + ) context = container.newBackgroundContext() } @@ -36,9 +40,18 @@ private extension NSPersistentContainer { case failedToPersistentStores(Error) } - static func load(modelName name: String, in bundle: Bundle) throws -> NSPersistentContainer { + static func load( + modelName name: String, + url: URL, + in bundle: Bundle + ) throws -> NSPersistentContainer { guard let model = NSManagedObjectModel.with(name: name, in: bundle) else { throw LoadingError.modelNotFound } let container = NSPersistentContainer(name: name, managedObjectModel: model) + + container.persistentStoreDescriptions = [ + NSPersistentStoreDescription(url: url) + ] + var loadError: Error? container.loadPersistentStores { loadError = $1 diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index 14a02e5..0d057e5 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -40,7 +40,11 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> CoreDataStore { let storeBundle = Bundle(for: CoreDataStore.self) - let sut = try! CoreDataStore(bundle: storeBundle) + let storeURL = URL(fileURLWithPath: "/dev/null") + let sut = try! CoreDataStore( + storeURL: storeURL, + bundle: storeBundle + ) trackForMemoryLeaks(sut, file: file, line: line) return sut } From c0a87d6b3f48c113c536f3c9921ea63ee8115747 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 09:59:13 +0200 Subject: [PATCH 114/159] `CoreDataFeedStore.retrieve()` delivers found values on non-empty cache --- .../Feed Cache/CoreDataStore.swift | 48 ++++++++++++++++++- .../Feed Cache/CoreDataFeedStoreSpecs.swift | 2 + 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift index ccfa252..faeaded 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift @@ -21,10 +21,54 @@ public final class CoreDataStore: FeedStore { } public func retrieve(completion: @escaping RetrievalCompletion) { - completion(.empty) + context.perform { [context] in + + do { + let request = NSFetchRequest(entityName: ManagedCache.entity().name!) + request.returnsObjectsAsFaults = false + if let cache = try context.fetch(request).first { + let feed = cache.feed + .compactMap { ($0 as? ManagedFeedImage) } + .map { + LocalFeedImage( + id: $0.id, + description: $0.imageDescription, + location: $0.location, + url: $0.url + ) + } + + completion(.found(feed: feed, timestamp: cache.timestamp)) + } else { + completion(.empty) + } + } catch { + completion(.failure(error)) + } + } } public func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { + context.perform { [context] in + do { + let managedCache = ManagedCache(context: context) + managedCache.timestamp = timestamp + let managedFeed = feed.map { + let managedFeedImage = ManagedFeedImage(context: context) + managedFeedImage.id = $0.id + managedFeedImage.imageDescription = $0.description + managedFeedImage.location = $0.location + managedFeedImage.url = $0.url + return managedFeedImage + } + + managedCache.feed = NSOrderedSet(array: managedFeed) + try context.save() + completion(nil) + } catch { + completion(error) + } + } } @@ -74,11 +118,13 @@ private extension NSManagedObjectModel { } } +@objc(ManagedCache) private class ManagedCache: NSManagedObject { @NSManaged var timestamp: Date @NSManaged var feed: NSOrderedSet } +@objc(ManagedFeedImage) private class ManagedFeedImage: NSManagedObject { @NSManaged var id: UUID @NSManaged var imageDescription: String? diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index 0d057e5..6aa2de4 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -20,6 +20,8 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { } func test_retrieve_deliversFoundValuesOnNonEmptyCache() { + let sut = makeSUT() + assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) } func test_retrieve_hasNoSideEffectsOnNonEmptyCache() { From 677ec7d353277ce4c55d75c8ed8097a4dd334ad9 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:05:42 +0200 Subject: [PATCH 115/159] Extract model translations into helper methods within the managed models --- .../Feed Cache/CoreDataStore.swift | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift index faeaded..ff09841 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift @@ -27,18 +27,7 @@ public final class CoreDataStore: FeedStore { let request = NSFetchRequest(entityName: ManagedCache.entity().name!) request.returnsObjectsAsFaults = false if let cache = try context.fetch(request).first { - let feed = cache.feed - .compactMap { ($0 as? ManagedFeedImage) } - .map { - LocalFeedImage( - id: $0.id, - description: $0.imageDescription, - location: $0.location, - url: $0.url - ) - } - - completion(.found(feed: feed, timestamp: cache.timestamp)) + completion(.found(feed: cache.localFeed, timestamp: cache.timestamp)) } else { completion(.empty) } @@ -53,16 +42,10 @@ public final class CoreDataStore: FeedStore { do { let managedCache = ManagedCache(context: context) managedCache.timestamp = timestamp - let managedFeed = feed.map { - let managedFeedImage = ManagedFeedImage(context: context) - managedFeedImage.id = $0.id - managedFeedImage.imageDescription = $0.description - managedFeedImage.location = $0.location - managedFeedImage.url = $0.url - return managedFeedImage - } - - managedCache.feed = NSOrderedSet(array: managedFeed) + managedCache.feed = ManagedFeedImage.images( + from: feed, + in: context + ) try context.save() completion(nil) } catch { @@ -122,6 +105,10 @@ private extension NSManagedObjectModel { private class ManagedCache: NSManagedObject { @NSManaged var timestamp: Date @NSManaged var feed: NSOrderedSet + + var localFeed: [LocalFeedImage] { + return feed.compactMap { ($0 as? ManagedFeedImage)?.local } + } } @objc(ManagedFeedImage) @@ -131,4 +118,25 @@ private class ManagedFeedImage: NSManagedObject { @NSManaged var location: String? @NSManaged var url: URL @NSManaged var cache: ManagedCache + + static func images(from localFeed: [LocalFeedImage], in context: NSManagedObjectContext) -> NSOrderedSet { + let managedFeedImages = localFeed.map { + let managedFeedImage = ManagedFeedImage(context: context) + managedFeedImage.id = $0.id + managedFeedImage.imageDescription = $0.description + managedFeedImage.location = $0.location + managedFeedImage.url = $0.url + return managedFeedImage + } + return NSOrderedSet(array: managedFeedImages) + } + + var local: LocalFeedImage { + LocalFeedImage( + id: id, + description: imageDescription, + location: location, + url: url + ) + } } From 94a41b3c4c908900b1f3643c311d88c8cf38af6a Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:08:39 +0200 Subject: [PATCH 116/159] Extract `ManagedCache` fetch request logic into a helper method within the managed model class --- .../EssentialFeed/Feed Cache/CoreDataStore.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift index ff09841..80c9e07 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift @@ -24,9 +24,7 @@ public final class CoreDataStore: FeedStore { context.perform { [context] in do { - let request = NSFetchRequest(entityName: ManagedCache.entity().name!) - request.returnsObjectsAsFaults = false - if let cache = try context.fetch(request).first { + if let cache = try ManagedCache.find(in: context) { completion(.found(feed: cache.localFeed, timestamp: cache.timestamp)) } else { completion(.empty) @@ -109,6 +107,12 @@ private class ManagedCache: NSManagedObject { var localFeed: [LocalFeedImage] { return feed.compactMap { ($0 as? ManagedFeedImage)?.local } } + + static func find(in context: NSManagedObjectContext) throws -> ManagedCache? { + let request = NSFetchRequest(entityName: entity().name!) + request.returnsObjectsAsFaults = false + return try context.fetch(request).first + } } @objc(ManagedFeedImage) From 0fa69a82c9bd1e2d05d702057c734dd2aaa36cd8 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:09:26 +0200 Subject: [PATCH 117/159] `CorerDataFeedStore.retrieve()` has no side-effects on non-empty cache --- .../EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index 6aa2de4..a5bdab8 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -25,6 +25,8 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { } func test_retrieve_hasNoSideEffectsOnNonEmptyCache() { + let sut = makeSUT() + assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) } func test_insert_overridesPreviouslyInsertedCacheValues() { From d3a9500852f0b50f15a2f580dd5133c2bb19acbe Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:17:27 +0200 Subject: [PATCH 118/159] Add missing inert methods in spec and implement testing in CodableStore --- .../Feed Cache/CodableFeedStoreTests.swift | 10 ++++++++ .../Feed Cache/CoreDataFeedStoreSpecs.swift | 3 +++ .../Feed Cache/FeedStoreSpecs.swift | 2 ++ .../XCTestCase+FeedStoreSpecs.swift | 23 ++++++++++++++++++- 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index d9188d1..92f62d1 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -40,6 +40,16 @@ class CodableFeedStoreTests: XCTestCase, FeedStoreSpecs { assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) } + func test_insert_deliversNoErrorOnEmptyCache() { + let sut = makeSUT() + assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) + } + + func test_insert_deliversNoErrorOnNonEmptyCache() { + let sut = makeSUT() + assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) + } + func test_insert_overridesPreviouslyInsertedCacheValues() { let sut = makeSUT() diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index a5bdab8..ce4ce74 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -29,6 +29,9 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) } + func test_insert_deliversNoErrorOnEmptyCache() {} + func test_insert_deliversNoErrorOnNonEmptyCache() {} + func test_insert_overridesPreviouslyInsertedCacheValues() { } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift index 78cea96..f3ca41b 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift @@ -11,6 +11,8 @@ protocol FeedStoreSpecs { func test_retrieve_deliversFoundValuesOnNonEmptyCache() func test_retrieve_hasNoSideEffectsOnNonEmptyCache() + func test_insert_deliversNoErrorOnEmptyCache() + func test_insert_deliversNoErrorOnNonEmptyCache() func test_insert_overridesPreviouslyInsertedCacheValues() func test_delete_hasNoSideEffectsOnEmptyCache() diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift index e5fa8c9..e70a6db 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift @@ -66,7 +66,9 @@ extension FeedStoreSpecs where Self: XCTestCase { expect(sut, toRetrieve: expectedResult, file: file, line: line) expect(sut, toRetrieve: expectedResult, file: file, line: line) } - +} + +extension FeedStoreSpecs where Self: XCTestCase { func assertThatRetrieveDeliversEmptyOnEmptyCache( on sut: FeedStore, file: StaticString = #file, @@ -106,6 +108,22 @@ extension FeedStoreSpecs where Self: XCTestCase { insert((feed, timestamp), to: sut) expect(sut, toRetrieveTwice: .found(feed: feed, timestamp: timestamp)) } +} + +extension FeedStoreSpecs where Self: XCTestCase { + func assertThatInsertDeliversNoErrorOnEmptyCache(on sut: FeedStore, file: StaticString = #file, line: UInt = #line) { + let insertionError = insert((uniqueImageFeed().local, Date()), to: sut) + + XCTAssertNil(insertionError, "Expected to insert cache successfully", file: file, line: line) + } + + func assertThatInsertDeliversNoErrorOnNonEmptyCache(on sut: FeedStore, file: StaticString = #file, line: UInt = #line) { + insert((uniqueImageFeed().local, Date()), to: sut) + + let insertionError = insert((uniqueImageFeed().local, Date()), to: sut) + + XCTAssertNil(insertionError, "Expected to override cache successfully", file: file, line: line) + } func assertThatInsertOverridesPreviouslyInsertedCacheValues( on sut: FeedStore, @@ -122,6 +140,9 @@ extension FeedStoreSpecs where Self: XCTestCase { XCTAssertNil(latestInsertionError, "Expected to override cache succesfully") expect(sut, toRetrieve: .found(feed: latestFeed, timestamp: latesTimestamp)) } +} + +extension FeedStoreSpecs where Self: XCTestCase { func assertThatDeleteHasNoSideEffectOnEmptyCache( on sut: FeedStore, From a01b3f2eec529d85c8e4b94788d536504cdcf2e4 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:18:18 +0200 Subject: [PATCH 119/159] `CoreDataStore.insert()` delivers no error on empty cache --- .../Feed Cache/CoreDataFeedStoreSpecs.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index ce4ce74..eead62b 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -29,7 +29,11 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) } - func test_insert_deliversNoErrorOnEmptyCache() {} + func test_insert_deliversNoErrorOnEmptyCache() { + let sut = makeSUT() + assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) + } + func test_insert_deliversNoErrorOnNonEmptyCache() {} func test_insert_overridesPreviouslyInsertedCacheValues() { From a93d785450e4c0469d8173c6681eacda64d3ca88 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:19:04 +0200 Subject: [PATCH 120/159] `CoreDataFeedStore.insert()` delivers no error on no empty cache --- .../Feed Cache/CoreDataFeedStoreSpecs.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index eead62b..1d3e9a3 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -34,7 +34,10 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) } - func test_insert_deliversNoErrorOnNonEmptyCache() {} + func test_insert_deliversNoErrorOnNonEmptyCache() { + let sut = makeSUT() + assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) + } func test_insert_overridesPreviouslyInsertedCacheValues() { } From 0da5da4bfe39ac69bfa8cb91f1256984d7a00177 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:23:44 +0200 Subject: [PATCH 121/159] `CoreDataFeedStore.insert()` overrides previously inserted cache values --- EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift | 7 ++++++- .../Feed Cache/CoreDataFeedStoreSpecs.swift | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift index 80c9e07..07eb144 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift @@ -38,7 +38,7 @@ public final class CoreDataStore: FeedStore { public func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { context.perform { [context] in do { - let managedCache = ManagedCache(context: context) + let managedCache = try ManagedCache.newUniqueInstance(in: context) managedCache.timestamp = timestamp managedCache.feed = ManagedFeedImage.images( from: feed, @@ -113,6 +113,11 @@ private class ManagedCache: NSManagedObject { request.returnsObjectsAsFaults = false return try context.fetch(request).first } + + static func newUniqueInstance(in context: NSManagedObjectContext) throws -> ManagedCache { + try find(in: context).map(context.delete) + return ManagedCache(context: context) + } } @objc(ManagedFeedImage) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index 1d3e9a3..19b874e 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -40,6 +40,8 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { } func test_insert_overridesPreviouslyInsertedCacheValues() { + let sut = makeSUT() + assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) } func test_delete_hasNoSideEffectsOnEmptyCache() { From 4da9ab99d8baf8c7665713d3f23c5f059ad93ff7 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:25:23 +0200 Subject: [PATCH 122/159] Add missing delete specs to FeedStoreSpecs --- .../EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift | 3 +++ .../EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift | 3 +++ .../EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift | 2 ++ 3 files changed, 8 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 92f62d1..8c06102 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -71,6 +71,9 @@ class CodableFeedStoreTests: XCTestCase, FeedStoreSpecs { assertThatStoreSideEffectsRunSerially(on: sut) } + func test_delete_deliversNoErrorOnEmptyCache() {} + func test_delete_deliversNoErrorOnNonEmptyCache() {} + // MARK: - Helpers private func makeSUT( storeURL: URL? = nil, diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index 19b874e..d2ccc14 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -44,6 +44,9 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) } + func test_delete_deliversNoErrorOnEmptyCache() {} + func test_delete_deliversNoErrorOnNonEmptyCache() {} + func test_delete_hasNoSideEffectsOnEmptyCache() { } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift index f3ca41b..625a31a 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift @@ -15,6 +15,8 @@ protocol FeedStoreSpecs { func test_insert_deliversNoErrorOnNonEmptyCache() func test_insert_overridesPreviouslyInsertedCacheValues() + func test_delete_deliversNoErrorOnEmptyCache() + func test_delete_deliversNoErrorOnNonEmptyCache() func test_delete_hasNoSideEffectsOnEmptyCache() func test_delete_emptiesPreviouslyInsertedCache() From 2eba6fadcea4150c38fcc0cf2ed3f1ef6f80ea05 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:27:32 +0200 Subject: [PATCH 123/159] `CodableFeedStore.delete()` does not deliver errors on empty cache --- .../Feed Cache/CodableFeedStoreTests.swift | 10 ++++++++-- .../Feed Cache/XCTestCase+FeedStoreSpecs.swift | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index 8c06102..a9fec36 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -71,8 +71,14 @@ class CodableFeedStoreTests: XCTestCase, FeedStoreSpecs { assertThatStoreSideEffectsRunSerially(on: sut) } - func test_delete_deliversNoErrorOnEmptyCache() {} - func test_delete_deliversNoErrorOnNonEmptyCache() {} + func test_delete_deliversNoErrorOnEmptyCache() { + let sut = makeSUT() + assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) + + } + + func test_delete_deliversNoErrorOnNonEmptyCache() { + } // MARK: - Helpers private func makeSUT( diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift index e70a6db..b613e7b 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift @@ -144,6 +144,12 @@ extension FeedStoreSpecs where Self: XCTestCase { extension FeedStoreSpecs where Self: XCTestCase { + func assertThatDeleteDeliversNoErrorOnEmptyCache(on sut: FeedStore, file: StaticString = #file, line: UInt = #line) { + let deletionError = deleteCache(from: sut) + + XCTAssertNil(deletionError, "Expected empty cache deletion to succeed", file: file, line: line) + } + func assertThatDeleteHasNoSideEffectOnEmptyCache( on sut: FeedStore, file: StaticString = #file, From 76090075464a30425a3b8a9ac1fcd622a9c72209 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:30:30 +0200 Subject: [PATCH 124/159] `CodableFeedStore.delete()` delivers no error on non empty cache --- .../Feed Cache/CodableFeedStoreTests.swift | 2 ++ .../Feed Cache/XCTestCase+FeedStoreSpecs.swift | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift index a9fec36..dc4237e 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift @@ -78,6 +78,8 @@ class CodableFeedStoreTests: XCTestCase, FeedStoreSpecs { } func test_delete_deliversNoErrorOnNonEmptyCache() { + let sut = makeSUT() + assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) } // MARK: - Helpers diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift index b613e7b..ca96c0f 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift @@ -150,6 +150,12 @@ extension FeedStoreSpecs where Self: XCTestCase { XCTAssertNil(deletionError, "Expected empty cache deletion to succeed", file: file, line: line) } + func assertThatDeleteDeliversNoErrorOnNonEmptyCache(on sut: FeedStore, file: StaticString = #file, line: UInt = #line) { + insert((uniqueImageFeed().local, Date()), to: sut) + let deletionError = deleteCache(from: sut) + XCTAssertNil(deletionError, "Expected empty cache deletion to succeed", file: file, line: line) + } + func assertThatDeleteHasNoSideEffectOnEmptyCache( on sut: FeedStore, file: StaticString = #file, From 9614c9b4b55dde8f6366225ebd5356abd75b3693 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:32:22 +0200 Subject: [PATCH 125/159] `CoreDataStore.delete()` delivers no error on empty cache --- EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift | 2 +- .../Feed Cache/CoreDataFeedStoreSpecs.swift | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift index 07eb144..c6e1a63 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift @@ -55,7 +55,7 @@ public final class CoreDataStore: FeedStore { public func deleteCachedFeed(completion: @escaping DeletionCompletion) { - + completion(nil) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index d2ccc14..7ea59c7 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -44,7 +44,11 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) } - func test_delete_deliversNoErrorOnEmptyCache() {} + func test_delete_deliversNoErrorOnEmptyCache() { + let sut = makeSUT() + assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) + } + func test_delete_deliversNoErrorOnNonEmptyCache() {} func test_delete_hasNoSideEffectsOnEmptyCache() { From 3ccd197516c3bceb963e1c646924de4c67f0de9c Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:33:03 +0200 Subject: [PATCH 126/159] `CoreDataStore.delete()` delivers no error on non empty cache --- .../Feed Cache/CoreDataFeedStoreSpecs.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index 7ea59c7..62a48a2 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -49,7 +49,10 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) } - func test_delete_deliversNoErrorOnNonEmptyCache() {} + func test_delete_deliversNoErrorOnNonEmptyCache() { + let sut = makeSUT() + assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) + } func test_delete_hasNoSideEffectsOnEmptyCache() { } From e708ae78621625fa5427b116ab3d537145a9ce3d Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:34:11 +0200 Subject: [PATCH 127/159] Rename `CoreDataStore` to `CoreDataFeedStore` thus adding pertinent domain term that better reflects the specificity of the object --- .../EssentialFeed.xcodeproj/project.pbxproj | 8 +- .../Feed Cache/CoreDataFeedStore.swift | 151 ++++++++++++++++++ .../Feed Cache/CoreDataStore.swift | 2 +- .../Feed Cache/CoreDataFeedStoreSpecs.swift | 6 +- 4 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 EssentialFeed/EssentialFeed/Feed Cache/CoreDataFeedStore.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index c8d9865..308f82e 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -19,7 +19,7 @@ 407F4FCA2DA402D80070F56E /* FeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */; }; 407F4FCC2DA403330070F56E /* XCTestCase+FeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FCB2DA403260070F56E /* XCTestCase+FeedStoreSpecs.swift */; }; 407F4FD12DA530810070F56E /* CoreDataFeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FD02DA5307B0070F56E /* CoreDataFeedStoreSpecs.swift */; }; - 407F4FD32DA531050070F56E /* CoreDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FD22DA530FF0070F56E /* CoreDataStore.swift */; }; + 407F4FD32DA531050070F56E /* CoreDataFeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FD22DA530FF0070F56E /* CoreDataFeedStore.swift */; }; 40B002492CF9E9DB0058D3E0 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 40B9753B2D9E7AE2009652B5 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */; }; 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */; }; @@ -62,7 +62,7 @@ 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStoreSpecs.swift; sourceTree = ""; }; 407F4FCB2DA403260070F56E /* XCTestCase+FeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+FeedStoreSpecs.swift"; sourceTree = ""; }; 407F4FD02DA5307B0070F56E /* CoreDataFeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataFeedStoreSpecs.swift; sourceTree = ""; }; - 407F4FD22DA530FF0070F56E /* CoreDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStore.swift; sourceTree = ""; }; + 407F4FD22DA530FF0070F56E /* CoreDataFeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataFeedStore.swift; sourceTree = ""; }; 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedAPIEndToEndTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedLoader.swift; sourceTree = ""; }; @@ -197,7 +197,7 @@ 40B975412D9E9FB7009652B5 /* Feed Cache */ = { isa = PBXGroup; children = ( - 407F4FD22DA530FF0070F56E /* CoreDataStore.swift */, + 407F4FD22DA530FF0070F56E /* CoreDataFeedStore.swift */, 40412A152DA65403004677C4 /* FeedStore.xcdatamodeld */, 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */, 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */, @@ -373,7 +373,7 @@ 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */, 40B975452D9EA01D009652B5 /* FeedStore.swift in Sources */, 407F4FB22DA022FF0070F56E /* CodableFeedStore.swift in Sources */, - 407F4FD32DA531050070F56E /* CoreDataStore.swift in Sources */, + 407F4FD32DA531050070F56E /* CoreDataFeedStore.swift in Sources */, 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataFeedStore.swift new file mode 100644 index 0000000..8df61fb --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataFeedStore.swift @@ -0,0 +1,151 @@ +// +// CoreDataStore.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 8/4/25. +// + +import CoreData + +public final class CoreDataFeedStore: FeedStore { + + let container: NSPersistentContainer + private let context: NSManagedObjectContext + public init(storeURL: URL, bundle: Bundle = .main) throws { + container = try NSPersistentContainer.load( + modelName: "FeedStore", + url: storeURL, + in: bundle + ) + context = container.newBackgroundContext() + } + + public func retrieve(completion: @escaping RetrievalCompletion) { + context.perform { [context] in + + do { + if let cache = try ManagedCache.find(in: context) { + completion(.found(feed: cache.localFeed, timestamp: cache.timestamp)) + } else { + completion(.empty) + } + } catch { + completion(.failure(error)) + } + } + } + + public func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { + context.perform { [context] in + do { + let managedCache = try ManagedCache.newUniqueInstance(in: context) + managedCache.timestamp = timestamp + managedCache.feed = ManagedFeedImage.images( + from: feed, + in: context + ) + try context.save() + completion(nil) + } catch { + completion(error) + } + } + + } + + + public func deleteCachedFeed(completion: @escaping DeletionCompletion) { + completion(nil) + } +} + +private extension NSPersistentContainer { + enum LoadingError: Error { + case modelNotFound + case failedToPersistentStores(Error) + } + + static func load( + modelName name: String, + url: URL, + in bundle: Bundle + ) throws -> NSPersistentContainer { + guard let model = NSManagedObjectModel.with(name: name, in: bundle) else { throw LoadingError.modelNotFound } + let container = NSPersistentContainer(name: name, managedObjectModel: model) + + container.persistentStoreDescriptions = [ + NSPersistentStoreDescription(url: url) + ] + + var loadError: Error? + container.loadPersistentStores { + loadError = $1 + } + + try loadError.map { + throw LoadingError.failedToPersistentStores($0) + } + + return container + } +} + +private extension NSManagedObjectModel { + static func with(name: String, in bundle: Bundle) -> NSManagedObjectModel? { + return bundle.url(forResource: name, withExtension: "momd") + .flatMap { + NSManagedObjectModel(contentsOf: $0) + } + } +} + +@objc(ManagedCache) +private class ManagedCache: NSManagedObject { + @NSManaged var timestamp: Date + @NSManaged var feed: NSOrderedSet + + var localFeed: [LocalFeedImage] { + return feed.compactMap { ($0 as? ManagedFeedImage)?.local } + } + + static func find(in context: NSManagedObjectContext) throws -> ManagedCache? { + let request = NSFetchRequest(entityName: entity().name!) + request.returnsObjectsAsFaults = false + return try context.fetch(request).first + } + + static func newUniqueInstance(in context: NSManagedObjectContext) throws -> ManagedCache { + try find(in: context).map(context.delete) + return ManagedCache(context: context) + } +} + +@objc(ManagedFeedImage) +private class ManagedFeedImage: NSManagedObject { + @NSManaged var id: UUID + @NSManaged var imageDescription: String? + @NSManaged var location: String? + @NSManaged var url: URL + @NSManaged var cache: ManagedCache + + static func images(from localFeed: [LocalFeedImage], in context: NSManagedObjectContext) -> NSOrderedSet { + let managedFeedImages = localFeed.map { + let managedFeedImage = ManagedFeedImage(context: context) + managedFeedImage.id = $0.id + managedFeedImage.imageDescription = $0.description + managedFeedImage.location = $0.location + managedFeedImage.url = $0.url + return managedFeedImage + } + return NSOrderedSet(array: managedFeedImages) + } + + var local: LocalFeedImage { + LocalFeedImage( + id: id, + description: imageDescription, + location: location, + url: url + ) + } +} diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift index c6e1a63..8df61fb 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift @@ -7,7 +7,7 @@ import CoreData -public final class CoreDataStore: FeedStore { +public final class CoreDataFeedStore: FeedStore { let container: NSPersistentContainer private let context: NSManagedObjectContext diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index 62a48a2..5574e1f 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -64,10 +64,10 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { } // MARK: - Helpers - private func makeSUT(file: StaticString = #file, line: UInt = #line) -> CoreDataStore { - let storeBundle = Bundle(for: CoreDataStore.self) + private func makeSUT(file: StaticString = #file, line: UInt = #line) -> CoreDataFeedStore { + let storeBundle = Bundle(for: CoreDataFeedStore.self) let storeURL = URL(fileURLWithPath: "/dev/null") - let sut = try! CoreDataStore( + let sut = try! CoreDataFeedStore( storeURL: storeURL, bundle: storeBundle ) From b66e6065da46d173076a9a96e6a0bcfc2629f155 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:34:52 +0200 Subject: [PATCH 128/159] `CoreDataFeedStore.delete()` has no side effects on empty cache --- .../EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index 5574e1f..5652931 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -55,6 +55,8 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { } func test_delete_hasNoSideEffectsOnEmptyCache() { + let sut = makeSUT() + assertThatDeleteHasNoSideEffectOnEmptyCache(on: sut) } func test_delete_emptiesPreviouslyInsertedCache() { From cf574bc7159115379aebd4127b80fae5b571728c Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:37:52 +0200 Subject: [PATCH 129/159] `CoreDataFeedStore.delete()` empties previously inserted cache --- .../EssentialFeed/Feed Cache/CoreDataFeedStore.swift | 11 ++++++++++- .../Feed Cache/CoreDataFeedStoreSpecs.swift | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataFeedStore.swift index 8df61fb..b8d2fd3 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataFeedStore.swift @@ -55,7 +55,16 @@ public final class CoreDataFeedStore: FeedStore { public func deleteCachedFeed(completion: @escaping DeletionCompletion) { - completion(nil) + context.perform { [context] in + do { + try ManagedCache.find(in: context) + .map(context.delete) + .map(context.save) + completion(nil) + } catch { + completion(error) + } + } } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index 5652931..fbffc38 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -60,6 +60,8 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { } func test_delete_emptiesPreviouslyInsertedCache() { + let sut = makeSUT() + assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) } func test_storeSideEffects_runSerially() { From a3cec19c3313c687b7b6e5a2c87611e4f24c6a5f Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 10:39:05 +0200 Subject: [PATCH 130/159] Proved that `CoreDataFeedStore` side-effects run serially --- .../EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index fbffc38..7f7ac9e 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -65,6 +65,8 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { } func test_storeSideEffects_runSerially() { + let sut = makeSUT() + assertThatStoreSideEffectsRunSerially(on: sut) } // MARK: - Helpers From c6fe8a19f5918901b93f13adbdbbf8efc1e6e646 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 11:10:25 +0200 Subject: [PATCH 131/159] Extract duplicate code into reusable helper method --- .../EssentialFeed/Feed Cache/CoreDataFeedStore.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataFeedStore.swift index b8d2fd3..c218b4f 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataFeedStore.swift @@ -21,7 +21,7 @@ public final class CoreDataFeedStore: FeedStore { } public func retrieve(completion: @escaping RetrievalCompletion) { - context.perform { [context] in + perform { context in do { if let cache = try ManagedCache.find(in: context) { @@ -36,7 +36,7 @@ public final class CoreDataFeedStore: FeedStore { } public func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { - context.perform { [context] in + perform { context in do { let managedCache = try ManagedCache.newUniqueInstance(in: context) managedCache.timestamp = timestamp @@ -55,7 +55,7 @@ public final class CoreDataFeedStore: FeedStore { public func deleteCachedFeed(completion: @escaping DeletionCompletion) { - context.perform { [context] in + perform { context in do { try ManagedCache.find(in: context) .map(context.delete) @@ -66,6 +66,12 @@ public final class CoreDataFeedStore: FeedStore { } } } + + private func perform(_ action: @escaping (NSManagedObjectContext) -> Void) { + context.perform { [context] in + action(context) + } + } } private extension NSPersistentContainer { From 64ac31f81ca7ed7c98855636156a3e84a5a34b31 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 11:12:04 +0200 Subject: [PATCH 132/159] Move `CoreDataFeedStore` and `CodableFeedStore` files to the new infrastructure folder --- .../EssentialFeed.xcodeproj/project.pbxproj | 22 ++++++++++++++++--- .../CodableFeedStore.swift | 0 .../CoreData}/CoreDataFeedStore.swift | 0 .../FeedStore.xcdatamodel/contents | 0 4 files changed, 19 insertions(+), 3 deletions(-) rename EssentialFeed/EssentialFeed/Feed Cache/{ => Infrastructure}/CodableFeedStore.swift (100%) rename EssentialFeed/EssentialFeed/Feed Cache/{ => Infrastructure/CoreData}/CoreDataFeedStore.swift (100%) rename EssentialFeed/EssentialFeed/Feed Cache/{ => Infrastructure/CoreData}/FeedStore.xcdatamodeld/FeedStore.xcdatamodel/contents (100%) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 308f82e..5efc976 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -179,6 +179,24 @@ path = "Feed Feature"; sourceTree = ""; }; + 40412A1A2DA67130004677C4 /* Infrastructure */ = { + isa = PBXGroup; + children = ( + 40412A1B2DA67135004677C4 /* CoreData */, + 407F4FB02DA022C00070F56E /* CodableFeedStore.swift */, + ); + path = Infrastructure; + sourceTree = ""; + }; + 40412A1B2DA67135004677C4 /* CoreData */ = { + isa = PBXGroup; + children = ( + 407F4FD22DA530FF0070F56E /* CoreDataFeedStore.swift */, + 40412A152DA65403004677C4 /* FeedStore.xcdatamodeld */, + ); + path = CoreData; + sourceTree = ""; + }; 40B975392D9E7AD3009652B5 /* Feed Cache */ = { isa = PBXGroup; children = ( @@ -197,13 +215,11 @@ 40B975412D9E9FB7009652B5 /* Feed Cache */ = { isa = PBXGroup; children = ( - 407F4FD22DA530FF0070F56E /* CoreDataFeedStore.swift */, - 40412A152DA65403004677C4 /* FeedStore.xcdatamodeld */, + 40412A1A2DA67130004677C4 /* Infrastructure */, 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */, 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */, 40B975442D9EA01D009652B5 /* FeedStore.swift */, 40B975482D9EA7A2009652B5 /* LocalFeedImage.swift */, - 407F4FB02DA022C00070F56E /* CodableFeedStore.swift */, ); path = "Feed Cache"; sourceTree = ""; diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CodableFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CodableFeedStore.swift similarity index 100% rename from EssentialFeed/EssentialFeed/Feed Cache/CodableFeedStore.swift rename to EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CodableFeedStore.swift diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift similarity index 100% rename from EssentialFeed/EssentialFeed/Feed Cache/CoreDataFeedStore.swift rename to EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.xcdatamodeld/FeedStore.xcdatamodel/contents b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/FeedStore.xcdatamodeld/FeedStore.xcdatamodel/contents similarity index 100% rename from EssentialFeed/EssentialFeed/Feed Cache/FeedStore.xcdatamodeld/FeedStore.xcdatamodel/contents rename to EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/FeedStore.xcdatamodeld/FeedStore.xcdatamodel/contents From 84ed2039dd50882454836644cf0aa27bc755b698 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 11:13:38 +0200 Subject: [PATCH 133/159] Extract reusable CoreData heleprs into a separate file --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 ++ .../CoreData/CoreDataFeedStore.swift | 40 ---------------- .../CoreData/CoreDataHelpers.swift | 48 +++++++++++++++++++ 3 files changed, 52 insertions(+), 40 deletions(-) create mode 100644 EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataHelpers.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 5efc976..85dd983 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 080EDF0C21B6DAE800813479 /* FeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0B21B6DAE800813479 /* FeedImage.swift */; }; 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0D21B6DCB600813479 /* FeedLoader.swift */; }; 40412A172DA65403004677C4 /* FeedStore.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 40412A152DA65403004677C4 /* FeedStore.xcdatamodeld */; }; + 40412A1D2DA6719E004677C4 /* CoreDataHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40412A1C2DA6719B004677C4 /* CoreDataHelpers.swift */; }; 407F4FA52D9FD7F90070F56E /* ValidateFeedCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */; }; 407F4FA72D9FDB810070F56E /* FeedCacheTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */; }; 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */; }; @@ -54,6 +55,7 @@ 080EDF0B21B6DAE800813479 /* FeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImage.swift; sourceTree = ""; }; 080EDF0D21B6DCB600813479 /* FeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoader.swift; sourceTree = ""; }; 40412A162DA65403004677C4 /* FeedStore.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = FeedStore.xcdatamodel; sourceTree = ""; }; + 40412A1C2DA6719B004677C4 /* CoreDataHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHelpers.swift; sourceTree = ""; }; 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateFeedCacheUseCaseTests.swift; sourceTree = ""; }; 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCacheTestHelpers.swift; sourceTree = ""; }; 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCachePolicy.swift; sourceTree = ""; }; @@ -191,6 +193,7 @@ 40412A1B2DA67135004677C4 /* CoreData */ = { isa = PBXGroup; children = ( + 40412A1C2DA6719B004677C4 /* CoreDataHelpers.swift */, 407F4FD22DA530FF0070F56E /* CoreDataFeedStore.swift */, 40412A152DA65403004677C4 /* FeedStore.xcdatamodeld */, ); @@ -382,6 +385,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 40412A1D2DA6719E004677C4 /* CoreDataHelpers.swift in Sources */, 40412A172DA65403004677C4 /* FeedStore.xcdatamodeld in Sources */, 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */, 080EDF0C21B6DAE800813479 /* FeedImage.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift index c218b4f..c8a8943 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -74,46 +74,6 @@ public final class CoreDataFeedStore: FeedStore { } } -private extension NSPersistentContainer { - enum LoadingError: Error { - case modelNotFound - case failedToPersistentStores(Error) - } - - static func load( - modelName name: String, - url: URL, - in bundle: Bundle - ) throws -> NSPersistentContainer { - guard let model = NSManagedObjectModel.with(name: name, in: bundle) else { throw LoadingError.modelNotFound } - let container = NSPersistentContainer(name: name, managedObjectModel: model) - - container.persistentStoreDescriptions = [ - NSPersistentStoreDescription(url: url) - ] - - var loadError: Error? - container.loadPersistentStores { - loadError = $1 - } - - try loadError.map { - throw LoadingError.failedToPersistentStores($0) - } - - return container - } -} - -private extension NSManagedObjectModel { - static func with(name: String, in bundle: Bundle) -> NSManagedObjectModel? { - return bundle.url(forResource: name, withExtension: "momd") - .flatMap { - NSManagedObjectModel(contentsOf: $0) - } - } -} - @objc(ManagedCache) private class ManagedCache: NSManagedObject { @NSManaged var timestamp: Date diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataHelpers.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataHelpers.swift new file mode 100644 index 0000000..9f6053f --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataHelpers.swift @@ -0,0 +1,48 @@ +// +// CoreDataHelpers.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 9/4/25. +// + +import CoreData + +extension NSPersistentContainer { + enum LoadingError: Error { + case modelNotFound + case failedToPersistentStores(Error) + } + + static func load( + modelName name: String, + url: URL, + in bundle: Bundle + ) throws -> NSPersistentContainer { + guard let model = NSManagedObjectModel.with(name: name, in: bundle) else { throw LoadingError.modelNotFound } + let container = NSPersistentContainer(name: name, managedObjectModel: model) + + container.persistentStoreDescriptions = [ + NSPersistentStoreDescription(url: url) + ] + + var loadError: Error? + container.loadPersistentStores { + loadError = $1 + } + + try loadError.map { + throw LoadingError.failedToPersistentStores($0) + } + + return container + } +} + +private extension NSManagedObjectModel { + static func with(name: String, in bundle: Bundle) -> NSManagedObjectModel? { + return bundle.url(forResource: name, withExtension: "momd") + .flatMap { + NSManagedObjectModel(contentsOf: $0) + } + } +} From 945032d044309838f20e379c82877fc3071a33b5 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 11:16:21 +0200 Subject: [PATCH 134/159] Extract CoreData managed classes into separate files --- .../EssentialFeed.xcodeproj/project.pbxproj | 8 +++ .../CoreData/CoreDataFeedStore.swift | 51 ------------------- .../CoreData/ManagedCache.swift | 29 +++++++++++ .../CoreData/ManagedFeedImage.swift | 38 ++++++++++++++ 4 files changed, 75 insertions(+), 51 deletions(-) create mode 100644 EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedCache.swift create mode 100644 EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedFeedImage.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 85dd983..25d1447 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0D21B6DCB600813479 /* FeedLoader.swift */; }; 40412A172DA65403004677C4 /* FeedStore.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 40412A152DA65403004677C4 /* FeedStore.xcdatamodeld */; }; 40412A1D2DA6719E004677C4 /* CoreDataHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40412A1C2DA6719B004677C4 /* CoreDataHelpers.swift */; }; + 40412A1F2DA67227004677C4 /* ManagedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40412A1E2DA67223004677C4 /* ManagedCache.swift */; }; + 40412A212DA67241004677C4 /* ManagedFeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40412A202DA6723E004677C4 /* ManagedFeedImage.swift */; }; 407F4FA52D9FD7F90070F56E /* ValidateFeedCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */; }; 407F4FA72D9FDB810070F56E /* FeedCacheTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */; }; 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */; }; @@ -56,6 +58,8 @@ 080EDF0D21B6DCB600813479 /* FeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoader.swift; sourceTree = ""; }; 40412A162DA65403004677C4 /* FeedStore.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = FeedStore.xcdatamodel; sourceTree = ""; }; 40412A1C2DA6719B004677C4 /* CoreDataHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHelpers.swift; sourceTree = ""; }; + 40412A1E2DA67223004677C4 /* ManagedCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedCache.swift; sourceTree = ""; }; + 40412A202DA6723E004677C4 /* ManagedFeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedFeedImage.swift; sourceTree = ""; }; 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateFeedCacheUseCaseTests.swift; sourceTree = ""; }; 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCacheTestHelpers.swift; sourceTree = ""; }; 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCachePolicy.swift; sourceTree = ""; }; @@ -193,6 +197,8 @@ 40412A1B2DA67135004677C4 /* CoreData */ = { isa = PBXGroup; children = ( + 40412A202DA6723E004677C4 /* ManagedFeedImage.swift */, + 40412A1E2DA67223004677C4 /* ManagedCache.swift */, 40412A1C2DA6719B004677C4 /* CoreDataHelpers.swift */, 407F4FD22DA530FF0070F56E /* CoreDataFeedStore.swift */, 40412A152DA65403004677C4 /* FeedStore.xcdatamodeld */, @@ -394,7 +400,9 @@ 40B975452D9EA01D009652B5 /* FeedStore.swift in Sources */, 407F4FB22DA022FF0070F56E /* CodableFeedStore.swift in Sources */, 407F4FD32DA531050070F56E /* CoreDataFeedStore.swift in Sources */, + 40412A1F2DA67227004677C4 /* ManagedCache.swift in Sources */, 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */, + 40412A212DA67241004677C4 /* ManagedFeedImage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift index c8a8943..0a73608 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -73,54 +73,3 @@ public final class CoreDataFeedStore: FeedStore { } } } - -@objc(ManagedCache) -private class ManagedCache: NSManagedObject { - @NSManaged var timestamp: Date - @NSManaged var feed: NSOrderedSet - - var localFeed: [LocalFeedImage] { - return feed.compactMap { ($0 as? ManagedFeedImage)?.local } - } - - static func find(in context: NSManagedObjectContext) throws -> ManagedCache? { - let request = NSFetchRequest(entityName: entity().name!) - request.returnsObjectsAsFaults = false - return try context.fetch(request).first - } - - static func newUniqueInstance(in context: NSManagedObjectContext) throws -> ManagedCache { - try find(in: context).map(context.delete) - return ManagedCache(context: context) - } -} - -@objc(ManagedFeedImage) -private class ManagedFeedImage: NSManagedObject { - @NSManaged var id: UUID - @NSManaged var imageDescription: String? - @NSManaged var location: String? - @NSManaged var url: URL - @NSManaged var cache: ManagedCache - - static func images(from localFeed: [LocalFeedImage], in context: NSManagedObjectContext) -> NSOrderedSet { - let managedFeedImages = localFeed.map { - let managedFeedImage = ManagedFeedImage(context: context) - managedFeedImage.id = $0.id - managedFeedImage.imageDescription = $0.description - managedFeedImage.location = $0.location - managedFeedImage.url = $0.url - return managedFeedImage - } - return NSOrderedSet(array: managedFeedImages) - } - - var local: LocalFeedImage { - LocalFeedImage( - id: id, - description: imageDescription, - location: location, - url: url - ) - } -} diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedCache.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedCache.swift new file mode 100644 index 0000000..5791986 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedCache.swift @@ -0,0 +1,29 @@ +// +// ManagedCache.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 9/4/25. +// + +import CoreData + +@objc(ManagedCache) +class ManagedCache: NSManagedObject { + @NSManaged var timestamp: Date + @NSManaged var feed: NSOrderedSet + + var localFeed: [LocalFeedImage] { + return feed.compactMap { ($0 as? ManagedFeedImage)?.local } + } + + static func find(in context: NSManagedObjectContext) throws -> ManagedCache? { + let request = NSFetchRequest(entityName: entity().name!) + request.returnsObjectsAsFaults = false + return try context.fetch(request).first + } + + static func newUniqueInstance(in context: NSManagedObjectContext) throws -> ManagedCache { + try find(in: context).map(context.delete) + return ManagedCache(context: context) + } +} diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedFeedImage.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedFeedImage.swift new file mode 100644 index 0000000..c86f016 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedFeedImage.swift @@ -0,0 +1,38 @@ +// +// ManagedFeedImage.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 9/4/25. +// + +import CoreData + +@objc(ManagedFeedImage) +class ManagedFeedImage: NSManagedObject { + @NSManaged var id: UUID + @NSManaged var imageDescription: String? + @NSManaged var location: String? + @NSManaged var url: URL + @NSManaged var cache: ManagedCache + + static func images(from localFeed: [LocalFeedImage], in context: NSManagedObjectContext) -> NSOrderedSet { + let managedFeedImages = localFeed.map { + let managedFeedImage = ManagedFeedImage(context: context) + managedFeedImage.id = $0.id + managedFeedImage.imageDescription = $0.description + managedFeedImage.location = $0.location + managedFeedImage.url = $0.url + return managedFeedImage + } + return NSOrderedSet(array: managedFeedImages) + } + + var local: LocalFeedImage { + LocalFeedImage( + id: id, + description: imageDescription, + location: location, + url: url + ) + } +} From 6095f8b3ad2df7369e12a91186899d65ef591625 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 11:17:29 +0200 Subject: [PATCH 135/159] Separate CoreData managed model classes data from helpers with extensions --- .../Feed Cache/Infrastructure/CoreData/ManagedCache.swift | 4 +++- .../Feed Cache/Infrastructure/CoreData/ManagedFeedImage.swift | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedCache.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedCache.swift index 5791986..d712a19 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedCache.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedCache.swift @@ -11,7 +11,9 @@ import CoreData class ManagedCache: NSManagedObject { @NSManaged var timestamp: Date @NSManaged var feed: NSOrderedSet - +} + +extension ManagedCache { var localFeed: [LocalFeedImage] { return feed.compactMap { ($0 as? ManagedFeedImage)?.local } } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedFeedImage.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedFeedImage.swift index c86f016..03bda54 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedFeedImage.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedFeedImage.swift @@ -14,6 +14,9 @@ class ManagedFeedImage: NSManagedObject { @NSManaged var location: String? @NSManaged var url: URL @NSManaged var cache: ManagedCache +} + +extension ManagedFeedImage { static func images(from localFeed: [LocalFeedImage], in context: NSManagedObjectContext) -> NSOrderedSet { let managedFeedImages = localFeed.map { From cd5df45038ba6dfccbe8807b10a62a651da147e5 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 11:26:19 +0200 Subject: [PATCH 136/159] Add cache integration test test target --- .../EssentialFeed.xcodeproj/project.pbxproj | 116 +++++++++++++++++- ...ssentialFeedCacheIntegrationTests.xcscheme | 55 +++++++++ .../EssentialFeedCacheIntegrationTests.swift | 12 ++ 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme create mode 100644 EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 25d1447..2467766 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 40412A1D2DA6719E004677C4 /* CoreDataHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40412A1C2DA6719B004677C4 /* CoreDataHelpers.swift */; }; 40412A1F2DA67227004677C4 /* ManagedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40412A1E2DA67223004677C4 /* ManagedCache.swift */; }; 40412A212DA67241004677C4 /* ManagedFeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40412A202DA6723E004677C4 /* ManagedFeedImage.swift */; }; + 40412A4E2DA67465004677C4 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; 407F4FA52D9FD7F90070F56E /* ValidateFeedCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */; }; 407F4FA72D9FDB810070F56E /* FeedCacheTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */; }; 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */; }; @@ -40,6 +41,13 @@ remoteGlobalIDString = 080EDEF021B6DA7E00813479; remoteInfo = EssentialFeed; }; + 40412A4F2DA67465004677C4 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 080EDEE821B6DA7E00813479 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 080EDEF021B6DA7E00813479; + remoteInfo = EssentialFeed; + }; 40B0024A2CF9E9DB0058D3E0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 080EDEE821B6DA7E00813479 /* Project object */; @@ -60,6 +68,7 @@ 40412A1C2DA6719B004677C4 /* CoreDataHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHelpers.swift; sourceTree = ""; }; 40412A1E2DA67223004677C4 /* ManagedCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedCache.swift; sourceTree = ""; }; 40412A202DA6723E004677C4 /* ManagedFeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedFeedImage.swift; sourceTree = ""; }; + 40412A4A2DA67465004677C4 /* EssentialFeedCacheIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedCacheIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateFeedCacheUseCaseTests.swift; sourceTree = ""; }; 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCacheTestHelpers.swift; sourceTree = ""; }; 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCachePolicy.swift; sourceTree = ""; }; @@ -102,6 +111,7 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 40124C322CF8BDD5008BBDB6 /* Feed Api */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Feed Api"; sourceTree = ""; }; + 40412A4B2DA67465004677C4 /* EssentialFeedCacheIntegrationTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EssentialFeedCacheIntegrationTests; sourceTree = ""; }; 40B002462CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EssentialFeedAPIEndToEndTests; sourceTree = ""; }; 40B9753D2D9E7CB2009652B5 /* Helpers */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (40B975402D9E7CB2009652B5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Helpers; sourceTree = ""; }; 40C57D7A2CF7C16E00518522 /* FeedApi */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (40C57D7D2CF7C19100518522 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = FeedApi; sourceTree = ""; }; @@ -123,6 +133,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 40412A472DA67465004677C4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 40412A4E2DA67465004677C4 /* EssentialFeed.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 40B002422CF9E9DB0058D3E0 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -140,6 +158,7 @@ 080EDEF321B6DA7E00813479 /* EssentialFeed */, 080EDEFE21B6DA7E00813479 /* EssentialFeedTests */, 40B002462CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */, + 40412A4B2DA67465004677C4 /* EssentialFeedCacheIntegrationTests */, 080EDEF221B6DA7E00813479 /* Products */, ); sourceTree = ""; @@ -150,6 +169,7 @@ 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */, 080EDEFA21B6DA7E00813479 /* EssentialFeedTests.xctest */, 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */, + 40412A4A2DA67465004677C4 /* EssentialFeedCacheIntegrationTests.xctest */, ); name = Products; sourceTree = ""; @@ -295,6 +315,29 @@ productReference = 080EDEFA21B6DA7E00813479 /* EssentialFeedTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 40412A492DA67465004677C4 /* EssentialFeedCacheIntegrationTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 40412A532DA67465004677C4 /* Build configuration list for PBXNativeTarget "EssentialFeedCacheIntegrationTests" */; + buildPhases = ( + 40412A462DA67465004677C4 /* Sources */, + 40412A472DA67465004677C4 /* Frameworks */, + 40412A482DA67465004677C4 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 40412A502DA67465004677C4 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 40412A4B2DA67465004677C4 /* EssentialFeedCacheIntegrationTests */, + ); + name = EssentialFeedCacheIntegrationTests; + packageProductDependencies = ( + ); + productName = EssentialFeedCacheIntegrationTests; + productReference = 40412A4A2DA67465004677C4 /* EssentialFeedCacheIntegrationTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 40B002442CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */ = { isa = PBXNativeTarget; buildConfigurationList = 40B0024C2CF9E9DB0058D3E0 /* Build configuration list for PBXNativeTarget "EssentialFeedAPIEndToEndTests" */; @@ -325,7 +368,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1600; + LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 1600; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -337,6 +380,9 @@ CreatedOnToolsVersion = 10.1; LastSwiftMigration = 1540; }; + 40412A492DA67465004677C4 = { + CreatedOnToolsVersion = 16.2; + }; 40B002442CF9E9DB0058D3E0 = { CreatedOnToolsVersion = 16.0; }; @@ -358,6 +404,7 @@ 080EDEF021B6DA7E00813479 /* EssentialFeed */, 080EDEF921B6DA7E00813479 /* EssentialFeedTests */, 40B002442CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */, + 40412A492DA67465004677C4 /* EssentialFeedCacheIntegrationTests */, ); }; /* End PBXProject section */ @@ -377,6 +424,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 40412A482DA67465004677C4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 40B002432CF9E9DB0058D3E0 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -422,6 +476,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 40412A462DA67465004677C4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 40B002412CF9E9DB0058D3E0 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -437,6 +498,11 @@ target = 080EDEF021B6DA7E00813479 /* EssentialFeed */; targetProxy = 080EDEFC21B6DA7E00813479 /* PBXContainerItemProxy */; }; + 40412A502DA67465004677C4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 080EDEF021B6DA7E00813479 /* EssentialFeed */; + targetProxy = 40412A4F2DA67465004677C4 /* PBXContainerItemProxy */; + }; 40B0024B2CF9E9DB0058D3E0 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 080EDEF021B6DA7E00813479 /* EssentialFeed */; @@ -680,6 +746,45 @@ }; name = Release; }; + 40412A512DA67465004677C4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = V73WZ9Y4HH; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.crisfe.EssentialFeedCacheIntegrationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 40412A522DA67465004677C4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = V73WZ9Y4HH; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.crisfe.EssentialFeedCacheIntegrationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; 40B0024D2CF9E9DB0058D3E0 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -749,6 +854,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 40412A532DA67465004677C4 /* Build configuration list for PBXNativeTarget "EssentialFeedCacheIntegrationTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 40412A512DA67465004677C4 /* Debug */, + 40412A522DA67465004677C4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 40B0024C2CF9E9DB0058D3E0 /* Build configuration list for PBXNativeTarget "EssentialFeedAPIEndToEndTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme new file mode 100644 index 0000000..2dba0a7 --- /dev/null +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift new file mode 100644 index 0000000..0833fb0 --- /dev/null +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift @@ -0,0 +1,12 @@ +// +// EssentialFeedCacheIntegrationTests.swift +// EssentialFeedCacheIntegrationTests +// +// Created by Cristian Felipe Patiño Rojas on 9/4/25. +// + +import XCTest + +final class EssentialFeedCacheIntegrationTests: XCTestCase { + +} From f3e49d7ac5dc210188bafd0eaa2995498c8c44c2 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 11:28:30 +0200 Subject: [PATCH 137/159] Randomize test exectuion order on cache integration tests --- .../EssentialFeed.xcodeproj/project.pbxproj | 2 ++ ...ssentialFeedCacheIntegrationTests.xcscheme | 9 +++++-- ...entialFeedCacheIntegrationTests.xctestplan | 25 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 EssentialFeed/EssentialFeedCacheIntegrationTests.xctestplan diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 2467766..7e1f208 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -69,6 +69,7 @@ 40412A1E2DA67223004677C4 /* ManagedCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedCache.swift; sourceTree = ""; }; 40412A202DA6723E004677C4 /* ManagedFeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedFeedImage.swift; sourceTree = ""; }; 40412A4A2DA67465004677C4 /* EssentialFeedCacheIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedCacheIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 40412A542DA6750C004677C4 /* EssentialFeedCacheIntegrationTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = EssentialFeedCacheIntegrationTests.xctestplan; sourceTree = ""; }; 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateFeedCacheUseCaseTests.swift; sourceTree = ""; }; 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCacheTestHelpers.swift; sourceTree = ""; }; 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCachePolicy.swift; sourceTree = ""; }; @@ -155,6 +156,7 @@ 080EDEE721B6DA7E00813479 = { isa = PBXGroup; children = ( + 40412A542DA6750C004677C4 /* EssentialFeedCacheIntegrationTests.xctestplan */, 080EDEF321B6DA7E00813479 /* EssentialFeed */, 080EDEFE21B6DA7E00813479 /* EssentialFeedTests */, 40B002462CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */, diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme index 2dba0a7..e950f52 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme @@ -11,8 +11,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + Date: Wed, 9 Apr 2025 11:29:59 +0200 Subject: [PATCH 138/159] Gather coverage on cache integration tests for the EssentialFeed target --- .../EssentialFeedCacheIntegrationTests.xctestplan | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests.xctestplan b/EssentialFeed/EssentialFeedCacheIntegrationTests.xctestplan index 8774989..3016214 100644 --- a/EssentialFeed/EssentialFeedCacheIntegrationTests.xctestplan +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests.xctestplan @@ -9,6 +9,15 @@ } ], "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "080EDEF021B6DA7E00813479", + "name" : "EssentialFeed" + } + ] + }, "testExecutionOrdering" : "random" }, "testTargets" : [ From b8e181e9932d756eb71bdd35b8dd03b3a7796b9f Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 11:46:10 +0200 Subject: [PATCH 139/159] Include memory leak tracking helper in the cache integration test targets --- EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 7e1f208..00423ed 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -89,6 +89,13 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 40412A562DA678BE004677C4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "XCTestCase+MemoryLeakTrackingHelper.swift", + ); + target = 40412A492DA67465004677C4 /* EssentialFeedCacheIntegrationTests */; + }; 40B975402D9E7CB2009652B5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( From b41456b3742971dd9761119110318f8997e2b23b Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 11:46:46 +0200 Subject: [PATCH 140/159] `LocalFeedLoader` in integration with the `CoreDataFeedStore` delivers no items on empty cache --- .../EssentialFeed.xcodeproj/project.pbxproj | 2 +- .../EssentialFeedCacheIntegrationTests.swift | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 00423ed..dafc120 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -121,7 +121,7 @@ 40124C322CF8BDD5008BBDB6 /* Feed Api */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Feed Api"; sourceTree = ""; }; 40412A4B2DA67465004677C4 /* EssentialFeedCacheIntegrationTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EssentialFeedCacheIntegrationTests; sourceTree = ""; }; 40B002462CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EssentialFeedAPIEndToEndTests; sourceTree = ""; }; - 40B9753D2D9E7CB2009652B5 /* Helpers */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (40B975402D9E7CB2009652B5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Helpers; sourceTree = ""; }; + 40B9753D2D9E7CB2009652B5 /* Helpers */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (40B975402D9E7CB2009652B5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 40412A562DA678BE004677C4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Helpers; sourceTree = ""; }; 40C57D7A2CF7C16E00518522 /* FeedApi */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (40C57D7D2CF7C19100518522 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = FeedApi; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift index 0833fb0..3cd556c 100644 --- a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift @@ -6,7 +6,50 @@ // import XCTest +import EssentialFeed final class EssentialFeedCacheIntegrationTests: XCTestCase { + + func test_load_deliversNoItemsOnEmptyCache() { + let sut = makeSUT() + + let exp = expectation(description: "Wait for load completion") + sut.load { result in + exp.fulfill() + switch result { + case let .success(imageFeed): + XCTAssertEqual(imageFeed, [], "Expected empty feed") + case let .failure(error): + XCTFail("Expected successful feed result, got \(error) instead") + } + } + + wait(for: [exp], timeout: 1.0) + } + // MARK: - Helpers + + private func makeSUT(file: StaticString = #file, line: UInt = #line) -> LocalFeedLoader { + let storeBundle = Bundle(for: CoreDataFeedStore.self) + let storeURL = testSpecificStoreURL() + let store = try! CoreDataFeedStore( + storeURL: storeURL, + bundle: storeBundle + ) + let sut = LocalFeedLoader( + store: store, + currentDate: Date.init + ) + trackForMemoryLeaks(sut, file: file, line: line) + trackForMemoryLeaks(store, file: file, line: line) + return sut + } + + private func testSpecificStoreURL() -> URL { + return cachesDirectory().appendingPathComponent("\(type(of: self)).store") + } + + private func cachesDirectory() -> URL { + return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + } } From 1caa8e6a999a266b47b11e575fe1cb8ee8b6649a Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 11:55:14 +0200 Subject: [PATCH 141/159] Include cache test helpers in the cache integration tests target --- EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index dafc120..90edca3 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 40412A1F2DA67227004677C4 /* ManagedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40412A1E2DA67223004677C4 /* ManagedCache.swift */; }; 40412A212DA67241004677C4 /* ManagedFeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40412A202DA6723E004677C4 /* ManagedFeedImage.swift */; }; 40412A4E2DA67465004677C4 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; }; + 40412A572DA67A9F004677C4 /* FeedCacheTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */; }; 407F4FA52D9FD7F90070F56E /* ValidateFeedCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */; }; 407F4FA72D9FDB810070F56E /* FeedCacheTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */; }; 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */; }; @@ -92,6 +93,7 @@ 40412A562DA678BE004677C4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + SharedTestHelpers.swift, "XCTestCase+MemoryLeakTrackingHelper.swift", ); target = 40412A492DA67465004677C4 /* EssentialFeedCacheIntegrationTests */; @@ -489,6 +491,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 40412A572DA67A9F004677C4 /* FeedCacheTestHelpers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From f33c3ca27a633bce1774352279d7d54848444130 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 11:59:10 +0200 Subject: [PATCH 142/159] Clean up & undo all cache side-effects on `setUp` and `tearDown` to avoid sharing state between tests --- .../EssentialFeedCacheIntegrationTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift index 3cd556c..027fc4d 100644 --- a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift @@ -9,6 +9,16 @@ import XCTest import EssentialFeed final class EssentialFeedCacheIntegrationTests: XCTestCase { + + override func setUp() { + super.setUp() + setupEmptyStoreState() + } + + override func tearDown() { + super.tearDown() + undoStoreSideEffects() + } func test_load_deliversNoItemsOnEmptyCache() { let sut = makeSUT() @@ -52,4 +62,16 @@ final class EssentialFeedCacheIntegrationTests: XCTestCase { private func cachesDirectory() -> URL { return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! } + + private func setupEmptyStoreState() { + deleteStoreArtifacts() + } + + private func undoStoreSideEffects() { + deleteStoreArtifacts() + } + + private func deleteStoreArtifacts() { + try? FileManager.default.removeItem(at: testSpecificStoreURL()) + } } From 5b7eca9ac5d2bba019e2f717511f0a27b6f91747 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 12:00:10 +0200 Subject: [PATCH 143/159] `LocalFeedLoader` in integration with `CoreDataFeedStore`delivers items saved on separated instances proving we correctly persist the data models to disk --- .../EssentialFeedCacheIntegrationTests.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift index 027fc4d..0d94428 100644 --- a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift @@ -37,6 +37,31 @@ final class EssentialFeedCacheIntegrationTests: XCTestCase { wait(for: [exp], timeout: 1.0) } + func test_load_deliversItemsSavedOnASeparatedInstance() { + let sutToPerformSave = makeSUT() + let sutToPerformLoad = makeSUT() + let feed = uniqueImageFeed().models + + let saveExp = expectation(description: "Wait for save completion") + sutToPerformSave.save(feed) { saveError in + XCTAssertNil(saveError, "Expected to save feed succesfully") + saveExp.fulfill() + } + wait(for: [saveExp], timeout: 1.0) + + let loadExp = expectation(description: "Wait for load completion") + sutToPerformLoad.load { result in + loadExp.fulfill() + switch result { + case let .success(imageFeed): + XCTAssertEqual(imageFeed, feed, "Expected saved feed") + case let .failure(error): + XCTFail("Expected successful feed result, got \(error) instead") + } + } + wait(for: [loadExp], timeout: 1.0) + } + // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> LocalFeedLoader { From 225e17791af41281176f03bf386ab86a7bf1fc9f Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 12:05:09 +0200 Subject: [PATCH 144/159] Extract duplicate cache load expectations into a shared helper method --- .../EssentialFeedCacheIntegrationTests.swift | 56 +++++++++++-------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift index 0d94428..f4a3d83 100644 --- a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift @@ -22,19 +22,7 @@ final class EssentialFeedCacheIntegrationTests: XCTestCase { func test_load_deliversNoItemsOnEmptyCache() { let sut = makeSUT() - - let exp = expectation(description: "Wait for load completion") - sut.load { result in - exp.fulfill() - switch result { - case let .success(imageFeed): - XCTAssertEqual(imageFeed, [], "Expected empty feed") - case let .failure(error): - XCTFail("Expected successful feed result, got \(error) instead") - } - } - - wait(for: [exp], timeout: 1.0) + expect(sut, toLoad: []) } func test_load_deliversItemsSavedOnASeparatedInstance() { @@ -49,17 +37,7 @@ final class EssentialFeedCacheIntegrationTests: XCTestCase { } wait(for: [saveExp], timeout: 1.0) - let loadExp = expectation(description: "Wait for load completion") - sutToPerformLoad.load { result in - loadExp.fulfill() - switch result { - case let .success(imageFeed): - XCTAssertEqual(imageFeed, feed, "Expected saved feed") - case let .failure(error): - XCTFail("Expected successful feed result, got \(error) instead") - } - } - wait(for: [loadExp], timeout: 1.0) + expect(sutToPerformLoad, toLoad: feed) } // MARK: - Helpers @@ -80,6 +58,36 @@ final class EssentialFeedCacheIntegrationTests: XCTestCase { return sut } + private func expect( + _ sut: LocalFeedLoader, + toLoad expectedImageFeed: [FeedImage], + file: StaticString = #file, + line: UInt = #line + ) { + let exp = expectation(description: "Wait for load completion") + sut.load { result in + exp.fulfill() + switch result { + case let .success(imageFeed): + XCTAssertEqual( + imageFeed, + expectedImageFeed, + "Expected empty feed", + file: file, + line: line + ) + case let .failure(error): + XCTFail( + "Expected successful feed result, got \(error) instead", + file: file, + line: line + ) + } + } + + wait(for: [exp], timeout: 1.0) + } + private func testSpecificStoreURL() -> URL { return cachesDirectory().appendingPathComponent("\(type(of: self)).store") } From ce790532cbc6a4b9b97e8390662a49fb85461b1a Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 12:10:41 +0200 Subject: [PATCH 145/159] `LocalFeedLoader` in integration with the `CoreDataFeedStore` overrides items saved by separated instances, proving we correctly manage the data models on disk; --- .../EssentialFeedCacheIntegrationTests.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift index f4a3d83..9cdca4b 100644 --- a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift @@ -40,6 +40,30 @@ final class EssentialFeedCacheIntegrationTests: XCTestCase { expect(sutToPerformLoad, toLoad: feed) } + func test_save_overridesItemsSavedOnASeparatedInstance() { + let sutToPerformFirstSave = makeSUT() + let sutToPerformLastSave = makeSUT() + let sutToPerformLoad = makeSUT() + let firstFeed = uniqueImageFeed().models + let latestFeed = uniqueImageFeed().models + + let saveExp1 = expectation(description: "Wait for save completion") + sutToPerformFirstSave.save(firstFeed) { saveError in + XCTAssertNil(saveError, "Expected to save feed succesfully") + saveExp1.fulfill() + } + wait(for: [saveExp1], timeout: 1.0) + + let saveExp2 = expectation(description: "Wait for save completion") + sutToPerformLastSave.save(latestFeed) { saveError in + XCTAssertNil(saveError, "Expected to save feed successfully") + saveExp2.fulfill() + } + wait(for: [saveExp2], timeout: 1.0) + + expect(sutToPerformLoad, toLoad: latestFeed) + } + // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> LocalFeedLoader { From 0eefecc53db604b50bd80ccc4c2ca13290367d49 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 12:15:10 +0200 Subject: [PATCH 146/159] Extract duplicated save operation into a shared helper method --- .../EssentialFeedCacheIntegrationTests.swift | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift index 9cdca4b..18e2f2c 100644 --- a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift @@ -30,12 +30,7 @@ final class EssentialFeedCacheIntegrationTests: XCTestCase { let sutToPerformLoad = makeSUT() let feed = uniqueImageFeed().models - let saveExp = expectation(description: "Wait for save completion") - sutToPerformSave.save(feed) { saveError in - XCTAssertNil(saveError, "Expected to save feed succesfully") - saveExp.fulfill() - } - wait(for: [saveExp], timeout: 1.0) + save(feed, with: sutToPerformSave) expect(sutToPerformLoad, toLoad: feed) } @@ -47,19 +42,8 @@ final class EssentialFeedCacheIntegrationTests: XCTestCase { let firstFeed = uniqueImageFeed().models let latestFeed = uniqueImageFeed().models - let saveExp1 = expectation(description: "Wait for save completion") - sutToPerformFirstSave.save(firstFeed) { saveError in - XCTAssertNil(saveError, "Expected to save feed succesfully") - saveExp1.fulfill() - } - wait(for: [saveExp1], timeout: 1.0) - - let saveExp2 = expectation(description: "Wait for save completion") - sutToPerformLastSave.save(latestFeed) { saveError in - XCTAssertNil(saveError, "Expected to save feed successfully") - saveExp2.fulfill() - } - wait(for: [saveExp2], timeout: 1.0) + save(firstFeed, with: sutToPerformFirstSave) + save(latestFeed, with: sutToPerformLastSave) expect(sutToPerformLoad, toLoad: latestFeed) } @@ -112,6 +96,21 @@ final class EssentialFeedCacheIntegrationTests: XCTestCase { wait(for: [exp], timeout: 1.0) } + + private func save( + _ feed: [FeedImage], + with sut: LocalFeedLoader, + file: StaticString = #file, + line: UInt = #line + ) { + let exp = expectation(description: "Wait for save completion") + sut.save(feed) { error in + XCTAssertNil(error, "Unexpected error: \(String(describing: error))", file: file, line: line) + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + } + private func testSpecificStoreURL() -> URL { return cachesDirectory().appendingPathComponent("\(type(of: self)).store") } From 11c5c47a95d0540b54c552077f5d3347e8bacfb7 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 12:21:23 +0200 Subject: [PATCH 147/159] Delete the `CodableFeedStore` in favor of the `CoreDataFeedStore`(we just need one in this project). If needed, of course, we can revert this commit and restore the `Codable` implementation --- .../EssentialFeed.xcodeproj/project.pbxproj | 8 - .../Infrastructure/CodableFeedStore.swift | 97 ---------- .../Feed Cache/CodableFeedStoreTests.swift | 176 ------------------ 3 files changed, 281 deletions(-) delete mode 100644 EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CodableFeedStore.swift delete mode 100644 EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 90edca3..0be8578 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -19,8 +19,6 @@ 407F4FA52D9FD7F90070F56E /* ValidateFeedCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */; }; 407F4FA72D9FDB810070F56E /* FeedCacheTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */; }; 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */; }; - 407F4FAF2D9FFF680070F56E /* CodableFeedStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */; }; - 407F4FB22DA022FF0070F56E /* CodableFeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FB02DA022C00070F56E /* CodableFeedStore.swift */; }; 407F4FCA2DA402D80070F56E /* FeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */; }; 407F4FCC2DA403330070F56E /* XCTestCase+FeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FCB2DA403260070F56E /* XCTestCase+FeedStoreSpecs.swift */; }; 407F4FD12DA530810070F56E /* CoreDataFeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407F4FD02DA5307B0070F56E /* CoreDataFeedStoreSpecs.swift */; }; @@ -74,8 +72,6 @@ 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateFeedCacheUseCaseTests.swift; sourceTree = ""; }; 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCacheTestHelpers.swift; sourceTree = ""; }; 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCachePolicy.swift; sourceTree = ""; }; - 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableFeedStoreTests.swift; sourceTree = ""; }; - 407F4FB02DA022C00070F56E /* CodableFeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableFeedStore.swift; sourceTree = ""; }; 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStoreSpecs.swift; sourceTree = ""; }; 407F4FCB2DA403260070F56E /* XCTestCase+FeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+FeedStoreSpecs.swift"; sourceTree = ""; }; 407F4FD02DA5307B0070F56E /* CoreDataFeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataFeedStoreSpecs.swift; sourceTree = ""; }; @@ -220,7 +216,6 @@ isa = PBXGroup; children = ( 40412A1B2DA67135004677C4 /* CoreData */, - 407F4FB02DA022C00070F56E /* CodableFeedStore.swift */, ); path = Infrastructure; sourceTree = ""; @@ -240,7 +235,6 @@ 40B975392D9E7AD3009652B5 /* Feed Cache */ = { isa = PBXGroup; children = ( - 407F4FAE2D9FFF640070F56E /* CodableFeedStoreTests.swift */, 407F4FD02DA5307B0070F56E /* CoreDataFeedStoreSpecs.swift */, 407F4FCB2DA403260070F56E /* XCTestCase+FeedStoreSpecs.swift */, 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */, @@ -463,7 +457,6 @@ 40B975492D9EA7A2009652B5 /* LocalFeedImage.swift in Sources */, 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */, 40B975452D9EA01D009652B5 /* FeedStore.swift in Sources */, - 407F4FB22DA022FF0070F56E /* CodableFeedStore.swift in Sources */, 407F4FD32DA531050070F56E /* CoreDataFeedStore.swift in Sources */, 40412A1F2DA67227004677C4 /* ManagedCache.swift in Sources */, 40B975432D9E9FC4009652B5 /* LocalFeedLoader.swift in Sources */, @@ -475,7 +468,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 407F4FAF2D9FFF680070F56E /* CodableFeedStoreTests.swift in Sources */, 407F4FA52D9FD7F90070F56E /* ValidateFeedCacheUseCaseTests.swift in Sources */, 40B9754E2D9EC15A009652B5 /* FeedStoreSpy.swift in Sources */, 407F4FD12DA530810070F56E /* CoreDataFeedStoreSpecs.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CodableFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CodableFeedStore.swift deleted file mode 100644 index d939603..0000000 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CodableFeedStore.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// CodableFeedStore.swift -// EssentialFeed -// -// Created by Cristian Felipe Patiño Rojas on 4/4/25. -// - - -import Foundation - -public class CodableFeedStore: FeedStore { - private struct Cache: Codable { - let feed: [CodableFeedImage] - let timestamp: Date - - var localFeed: [LocalFeedImage] { - feed.map { - LocalFeedImage( - id: $0.id, - description: $0.description, - location: $0.location, - url: $0.url - ) - } - } - } - - private struct CodableFeedImage: Codable { - fileprivate let id: UUID - fileprivate let description: String? - fileprivate let location: String? - fileprivate let url: URL - - init(_ image: LocalFeedImage) { - id = image.id - description = image.description - location = image.location - url = image.url - } - } - - private let storeURL: URL - private let queue = DispatchQueue( - label: "\(CodableFeedStore.self)-queue", - qos: .userInitiated, - attributes: .concurrent - ) - - public init(storeURL: URL) { - self.storeURL = storeURL - } - - public func retrieve(completion: @escaping RetrievalCompletion) { - queue.async { [storeURL] in - guard let data = try? Data(contentsOf: storeURL) else { - return completion(.empty) - } - - do { - let decoder = JSONDecoder() - let cache = try decoder.decode(Cache.self, from: data) - completion(.found(feed: cache.localFeed, timestamp: cache.timestamp)) - } catch { - completion(.failure(error)) - } - } - } - - public func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { - queue.async(flags: .barrier) { [storeURL] in - do { - let encoder = JSONEncoder() - let cache = Cache(feed: feed.map(CodableFeedImage.init), timestamp: timestamp) - let encoded = try encoder.encode(cache) - try encoded.write(to: storeURL) - completion(nil) - } catch { - completion(error) - } - } - } - - public func deleteCachedFeed(completion: @escaping DeletionCompletion) { - queue.async(flags: .barrier) { [storeURL] in - guard FileManager.default.fileExists(atPath: storeURL.path) else { - return completion(nil) - } - - do { - try FileManager.default.removeItem(at: storeURL) - completion(nil) - } catch { - completion(error) - } - } - } -} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift deleted file mode 100644 index dc4237e..0000000 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CodableFeedStoreTests.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// CodableFeedStoreTests.swift -// EssentialFeed -// -// Created by Cristian Felipe Patiño Rojas on 4/4/25. -// - -import XCTest -import EssentialFeed - -class CodableFeedStoreTests: XCTestCase, FeedStoreSpecs { - - override func setUp() { - super.setUp() - setupEmptyStoreState() - } - - override func tearDown() { - super.setUp() - undoStoreSideEfects() - } - - func test_retrieve_deliversEmptyOnEmptyCache() { - let sut = makeSUT() - assertThatStoreSideEffectsRunSerially(on: sut) - } - - func test_retrieve_hasNoSideEffectsOnEmptyCache() { - let sut = makeSUT() - assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) - } - - func test_retrieve_deliversFoundValuesOnNonEmptyCache() { - let sut = makeSUT() - assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) - } - - func test_retrieve_hasNoSideEffectsOnNonEmptyCache() { - let sut = makeSUT() - assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) - } - - func test_insert_deliversNoErrorOnEmptyCache() { - let sut = makeSUT() - assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) - } - - func test_insert_deliversNoErrorOnNonEmptyCache() { - let sut = makeSUT() - assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) - } - - func test_insert_overridesPreviouslyInsertedCacheValues() { - let sut = makeSUT() - - assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) - } - - func test_delete_hasNoSideEffectsOnEmptyCache() { - let sut = makeSUT() - assertThatDeleteHasNoSideEffectOnEmptyCache(on: sut) - } - - func test_delete_emptiesPreviouslyInsertedCache() { - let sut = makeSUT() - assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) - } - - func test_storeSideEffects_runSerially() { - let sut = makeSUT() - assertThatStoreSideEffectsRunSerially(on: sut) - } - - func test_delete_deliversNoErrorOnEmptyCache() { - let sut = makeSUT() - assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) - - } - - func test_delete_deliversNoErrorOnNonEmptyCache() { - let sut = makeSUT() - assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) - } - - // MARK: - Helpers - private func makeSUT( - storeURL: URL? = nil, - file: StaticString = #file, - line: UInt = #line - ) -> FeedStore { - let sut = CodableFeedStore(storeURL: storeURL ?? testSpecificStoreURL()) - trackForMemoryLeaks(sut, file: file, line: line) - return sut - } - - private func systemMaskCachesDirectory() -> URL { - FileManager.default.urls(for: .cachesDirectory, in: .systemDomainMask).first! - } - - private func testSpecificStoreURL() -> URL { - FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("\(type(of: self)).store") - } - - private func setupEmptyStoreState() { - deleteStoreArtifacts() - } - - private func undoStoreSideEfects() { - deleteStoreArtifacts() - } - - private func deleteStoreArtifacts() { - try? FileManager.default.removeItem(at: testSpecificStoreURL()) - } -} - -extension CodableFeedStoreTests: FailableRetrieveFeedStoreSpecs { - func test_retrieve_deliversFailureOnRetrievalError() { - let storeURL = testSpecificStoreURL() - let sut = makeSUT(storeURL: storeURL) - - try! "invalid data".write(to: storeURL, atomically: false, encoding: .utf8) - - expect(sut, toRetrieve: .failure(anyNSError())) - } - - func test_retrieve_hasNoSideEffectsOnFailure() { - let storeURL = testSpecificStoreURL() - let sut = makeSUT(storeURL: storeURL) - - try! "invalid data".write(to: storeURL, atomically: false, encoding: .utf8) - - expect(sut, toRetrieveTwice: .failure(anyNSError())) - } -} - -extension CodableFeedStoreTests: FailableInsertFeedStoreSpecs { - func test_insert_deliversErrorOnInsertionError() { - let invalidStoreURL = URL(string: "invalid://store-url") - let sut = makeSUT(storeURL: invalidStoreURL) - let feed = uniqueImageFeed().local - let timestamp = Date() - let insertionError = insert((feed, timestamp), to: sut) - XCTAssertNotNil(insertionError, "Expected cache insertion to fail with an error") - } - - func test_insert_hasNoSideEffectsOnInsertionError() { - let invalidStoreURL = URL(string: "invalid://store-url") - let sut = makeSUT(storeURL: invalidStoreURL) - let feed = uniqueImageFeed().local - let timestamp = Date() - - insert((feed, timestamp), to: sut) - expect(sut, toRetrieve: .empty) - } -} - - -extension CodableFeedStoreTests: FailableDeleteFeedStoreSpecs { - func test_delete_deliversErrorOnDeletionError() { - let noDeletePermissionURL = systemMaskCachesDirectory() - let sut = makeSUT(storeURL: noDeletePermissionURL) - - let deletionError = deleteCache(from: sut) - - XCTAssertNotNil(deletionError, "Expect cache deletion to fail") - } - - func test_delete_hasNoSideEffectsOnDeletionError() { - let noDeletePermissionURL = systemMaskCachesDirectory() - let sut = makeSUT(storeURL: noDeletePermissionURL) - - deleteCache(from: sut) - expect(sut, toRetrieve: .empty) - } -} From c11b385da268e56b7b08028ddb984051f5070ce6 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 12:25:30 +0200 Subject: [PATCH 148/159] Include `EssentialFeedCacheIntegrationTests`test target in the CI scheme to guarantee we build and run all cahce integration tests as part of the continuos integration pipeline. --- EssentialFeed/CI.xctestplan | 38 +++++++++++++++++++ .../xcshareddata/xcschemes/CI.xcscheme | 9 ++++- 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 EssentialFeed/CI.xctestplan diff --git a/EssentialFeed/CI.xctestplan b/EssentialFeed/CI.xctestplan new file mode 100644 index 0000000..a769de9 --- /dev/null +++ b/EssentialFeed/CI.xctestplan @@ -0,0 +1,38 @@ +{ + "configurations" : [ + { + "id" : "FB6DC599-8749-4C8A-B598-7DA402070AD5", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "testExecutionOrdering" : "random" + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "080EDEF921B6DA7E00813479", + "name" : "EssentialFeedTests" + } + }, + { + "target" : { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "40B002442CF9E9DB0058D3E0", + "name" : "EssentialFeedAPIEndToEndTests" + } + }, + { + "target" : { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "40412A492DA67465004677C4", + "name" : "EssentialFeedCacheIntegrationTests" + } + } + ], + "version" : 1 +} diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI.xcscheme index 2f55a46..5e69754 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI.xcscheme +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI.xcscheme @@ -27,8 +27,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + From 9b6a4af5e4dabde7d99bf6072503f748ac608a84 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 12:32:06 +0200 Subject: [PATCH 149/159] Remove redunant `internal` access control declarations --- .../EssentialFeed/Feed Cache/FeedCachePolicy.swift | 2 +- .../EssentialFeed/FeedApi/FeedItemsMapper.swift | 4 ++-- .../EssentialFeed/FeedApi/RemoteFeedItem.swift | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedCachePolicy.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedCachePolicy.swift index 4b5450e..73dd51d 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedCachePolicy.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedCachePolicy.swift @@ -7,7 +7,7 @@ import Foundation -internal final class FeedCachePolicy { +final class FeedCachePolicy { private static let calendar = Calendar(identifier: .gregorian) private static var maxCacheAgeInDays: Int { return 7 } private init() {} diff --git a/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift b/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift index 71a6644..5ddec9c 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift @@ -8,7 +8,7 @@ import Foundation -internal enum FeedItemsMapper { +enum FeedItemsMapper { struct Root: Decodable { let items: [RemoteFeedItem] @@ -17,7 +17,7 @@ internal enum FeedItemsMapper { private static let jsonDecoder = JSONDecoder() private static let OK_200 = 200 - internal static func map(_ data: Data, from response: HTTPURLResponse) throws -> [RemoteFeedItem] { + static func map(_ data: Data, from response: HTTPURLResponse) throws -> [RemoteFeedItem] { guard response.statusCode == OK_200, let root = try? jsonDecoder.decode(Root.self, from: data) else { diff --git a/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedItem.swift b/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedItem.swift index fff12ee..6589a30 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedItem.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedItem.swift @@ -6,9 +6,9 @@ // import Foundation -internal struct RemoteFeedItem: Decodable { - internal let id: UUID - internal let description: String? - internal let location: String? - internal let image: URL +struct RemoteFeedItem: Decodable { + let id: UUID + let description: String? + let location: String? + let image: URL } From e12c8acb4bc68c014a327c65807fb2abb2aecb59 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 12:35:16 +0200 Subject: [PATCH 150/159] Replace custom `LoadFeedResult` enum with standard swift result type --- EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift index acd3d92..861aa06 100644 --- a/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift @@ -4,10 +4,7 @@ import Foundation -public enum LoadFeedResult { - case success([FeedImage]) - case failure(Error) -} +public typealias LoadFeedResult = Result<[FeedImage], Error> public protocol FeedLoader { func load(completion: @escaping (LoadFeedResult) -> Void) From b384d3e6a4c4f42ab46f3c7083327324005749f4 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 12:37:33 +0200 Subject: [PATCH 151/159] Nest `LocalFeedResult` into the `FeedLoader` protocol as `FeedLoader.Result`sinte they're closely related --- EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift | 2 +- EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift | 4 ++-- EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift | 2 +- .../EssentialFeedAPIEndToEndTests.swift | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 191acf1..8739b19 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -18,7 +18,7 @@ public final class LocalFeedLoader: FeedLoader { } extension LocalFeedLoader { - public typealias LoadResult = LoadFeedResult + public typealias LoadResult = FeedLoader.Result public func load(completion: @escaping (LoadResult) -> Void) { store.retrieve { [weak self] result in guard let self else { return } diff --git a/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift index 861aa06..c447b7e 100644 --- a/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift @@ -4,8 +4,8 @@ import Foundation -public typealias LoadFeedResult = Result<[FeedImage], Error> public protocol FeedLoader { - func load(completion: @escaping (LoadFeedResult) -> Void) + typealias Result = Swift.Result<[FeedImage], Error> + func load(completion: @escaping (Result) -> Void) } diff --git a/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift b/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift index 5f2c838..2242520 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift @@ -16,7 +16,7 @@ public final class RemoteFeedLoader: FeedLoader { case invalidData } - public typealias Result = LoadFeedResult + public typealias Result = FeedLoader.Result public init(url: URL, client: HTTPClient) { self.url = url diff --git a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift index be50db2..7d0402a 100644 --- a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift +++ b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift @@ -32,7 +32,7 @@ final class EssentialFeedAPIEndToEndTests: XCTestCase { func getFeedResult( file: StaticString = #file, line: UInt = #line - ) -> LoadFeedResult? { + ) -> FeedLoader.Result? { let testServerURL = URL(string: "https://essentialdeveloper.com/feed-case-study/test-api/feed")! let client = URLSessionHTTPClient() let loader = RemoteFeedLoader( @@ -44,7 +44,7 @@ final class EssentialFeedAPIEndToEndTests: XCTestCase { let exp = expectation(description: "Wait for load completion") - var receivedResult: LoadFeedResult? + var receivedResult: FeedLoader.Result? loader.load { result in receivedResult = result From 840aea89abcc22f94983fda8ec2ad4ff68772e97 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 12:41:35 +0200 Subject: [PATCH 152/159] Replace custom `HTTPClientResult` enum with a typealias over the standard `Swift.Result` --- EssentialFeed/EssentialFeed/FeedApi/HTTPClient.swift | 7 ++----- EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift | 2 +- .../EssentialFeed/FeedApi/URLSessionHTTPClient.swift | 4 ++-- .../Feed Api/LoadFeedFromRemoteUseCaseTests.swift | 6 +++--- .../Feed Api/URLSessionHTTPClientTests.swift | 6 +++--- 5 files changed, 11 insertions(+), 14 deletions(-) diff --git a/EssentialFeed/EssentialFeed/FeedApi/HTTPClient.swift b/EssentialFeed/EssentialFeed/FeedApi/HTTPClient.swift index aa292c8..068e1c3 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/HTTPClient.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/HTTPClient.swift @@ -6,14 +6,11 @@ // import Foundation -public enum HTTPClientResult { - case success(Data, HTTPURLResponse) - case failure(Error) -} public protocol HTTPClient { + typealias Result = Swift.Result<(Data, HTTPURLResponse), Error> /// The completion handler can be invoked in any thread. /// Clients are responsible to dispatch to appropiate threads, if needed. - func get(from url: URL, completion: @escaping (HTTPClientResult) -> Void) + func get(from url: URL, completion: @escaping (Result) -> Void) } diff --git a/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift b/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift index 2242520..b198fa6 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift @@ -27,7 +27,7 @@ public final class RemoteFeedLoader: FeedLoader { client.get(from: url) { [weak self] result in guard self != nil else { return } switch result { - case let .success(data, response): + case let .success((data, response)): completion(Self.map(data, from: response)) case .failure: completion(.failure(Error.connectivity)) diff --git a/EssentialFeed/EssentialFeed/FeedApi/URLSessionHTTPClient.swift b/EssentialFeed/EssentialFeed/FeedApi/URLSessionHTTPClient.swift index a8b0f85..e97d481 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/URLSessionHTTPClient.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/URLSessionHTTPClient.swift @@ -15,12 +15,12 @@ public class URLSessionHTTPClient: HTTPClient { struct UnexpectedValuesRepresentation: Error {} - public func get(from url: URL, completion: @escaping (HTTPClientResult) -> Void) { + public func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) { session.dataTask(with: url) { data, response, error in if let error { completion(.failure(error)) } else if let data, let response = response as? HTTPURLResponse { - completion(.success(data, response)) + completion(.success((data, response))) } else { completion(.failure(UnexpectedValuesRepresentation())) } diff --git a/EssentialFeed/EssentialFeedTests/Feed Api/LoadFeedFromRemoteUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Api/LoadFeedFromRemoteUseCaseTests.swift index 03fa678..5cc37d1 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Api/LoadFeedFromRemoteUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Api/LoadFeedFromRemoteUseCaseTests.swift @@ -174,9 +174,9 @@ class LoadFeedFromRemoteUseCaseTests: XCTestCase { messages.map(\.url) } - private var messages = [(url: URL, completion: (HTTPClientResult) -> Void)]() + private var messages = [(url: URL, completion: (HTTPClient.Result) -> Void)]() - func get(from url: URL, completion: @escaping (HTTPClientResult) -> Void) { + func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) { messages.append((url, completion)) } @@ -192,7 +192,7 @@ class LoadFeedFromRemoteUseCaseTests: XCTestCase { headerFields: nil )! - messages[index].completion(.success(data, response)) + messages[index].completion(.success((data, response))) } } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Api/URLSessionHTTPClientTests.swift b/EssentialFeed/EssentialFeedTests/Feed Api/URLSessionHTTPClientTests.swift index 6e36dba..f0c73ec 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Api/URLSessionHTTPClientTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Api/URLSessionHTTPClientTests.swift @@ -118,7 +118,7 @@ class URLSessionHTTPClientTests: XCTestCase { switch result { - case let .success(data, response): + case let .success((data, response)): return (data: data, response: response) default: XCTFail( @@ -136,7 +136,7 @@ class URLSessionHTTPClientTests: XCTestCase { error: Error?, file: StaticString = #file, line: UInt = #line - ) -> HTTPClientResult { + ) -> HTTPClient.Result { let url = anyURL() let sut = makeSUT(file: file, line: line) URLProtocolStub.stub( @@ -146,7 +146,7 @@ class URLSessionHTTPClientTests: XCTestCase { let exp = expectation(description: "Wait for completion") - var receivedResult: HTTPClientResult! + var receivedResult: HTTPClient.Result! sut.get(from: url) { result in receivedResult = result From 45a738e673ce44a72455a4ee0b260ad7c7d16ac1 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 12:50:40 +0200 Subject: [PATCH 153/159] Replace custom `RetrieveCacheFeedResult`enum with a nest typealais over the standard `Swift.Result`(`FeedStore.RetrievalResult`) --- .../EssentialFeed/Feed Cache/FeedStore.swift | 9 +++++--- .../CoreData/CoreDataFeedStore.swift | 4 ++-- .../Feed Cache/LocalFeedLoader.swift | 8 +++---- .../Feed Cache/Helpers/FeedStoreSpy.swift | 4 ++-- .../XCTestCase+FeedStoreSpecs.swift | 22 +++++++++---------- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift index 26b34a8..03b80a3 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -7,16 +7,19 @@ import Foundation -public enum RetrieveCachedFeedResult { + +public enum CacheFeed { case empty case found(feed: [LocalFeedImage], timestamp: Date) - case failure(Error) } public protocol FeedStore { typealias DeletionCompletion = (Error?) -> Void typealias InsertionCompletion = (Error?) -> Void - typealias RetrievalCompletion = (RetrieveCachedFeedResult) -> Void + + + typealias RetrievalResult = Result + typealias RetrievalCompletion = (RetrievalResult) -> Void /// The completion handler can be invoked in any thread. /// Clients are responsible to dispatch to appropiate threads, if needed. diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift index 0a73608..e2f080d 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -25,9 +25,9 @@ public final class CoreDataFeedStore: FeedStore { do { if let cache = try ManagedCache.find(in: context) { - completion(.found(feed: cache.localFeed, timestamp: cache.timestamp)) + completion(.success(.found(feed: cache.localFeed, timestamp: cache.timestamp))) } else { - completion(.empty) + completion(.success(.empty)) } } catch { completion(.failure(error)) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 8739b19..2a1c387 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -26,9 +26,9 @@ extension LocalFeedLoader { case let .failure(error): completion(.failure(error)) - case let .found(feed, timestamp) where FeedCachePolicy.validate(timestamp, against: currentDate()): + case let .success(.found(feed, timestamp)) where FeedCachePolicy.validate(timestamp, against: currentDate()): completion(.success(feed.toModels())) - case .found, .empty: + case .success: completion(.success([])) } } @@ -40,11 +40,11 @@ extension LocalFeedLoader { store.retrieve { [weak self] result in guard let self else { return } switch result { - case let .found(_, timestamp) where !FeedCachePolicy.validate(timestamp, against: currentDate()): + case let .success(.found(_, timestamp)) where !FeedCachePolicy.validate(timestamp, against: currentDate()): self.store.deleteCachedFeed { _ in } case .failure: self.store.deleteCachedFeed { _ in } - case .empty, .found: break + case .success: break } } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift index d142bce..f916ccc 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift @@ -59,10 +59,10 @@ class FeedStoreSpy: FeedStore { } func completeRetrievalWithEmptyCache(at index: Int = 0) { - retrievalCompletions[index](.empty) + retrievalCompletions[index](.success(.empty)) } func completeRetrieval(with feed: [LocalFeedImage], timestamp: Date, at index: Int = 0) { - retrievalCompletions[index](.found(feed: feed, timestamp: timestamp)) + retrievalCompletions[index](.success(.found(feed: feed, timestamp: timestamp))) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift index ca96c0f..f714ea8 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift @@ -36,7 +36,7 @@ extension FeedStoreSpecs where Self: XCTestCase { func expect( _ sut: FeedStore, - toRetrieve expectedResult: RetrieveCachedFeedResult, + toRetrieve expectedResult: FeedStore.RetrievalResult, file: StaticString = #file, line: UInt = #line ) { @@ -45,9 +45,9 @@ extension FeedStoreSpecs where Self: XCTestCase { sut.retrieve { retrievedResult in exp.fulfill() switch (expectedResult, retrievedResult) { - case (.empty, .empty), (.failure, .failure): + case (.success(.empty), .success(.empty)), (.failure, .failure): break - case let (.found(expectedFeed, expectedTimestmap), .found(retrievedFeed, retrievedTimestamp)): + case let (.success(.found(expectedFeed, expectedTimestmap)), .success(.found(retrievedFeed, retrievedTimestamp))): XCTAssertEqual(expectedFeed, retrievedFeed, file: file, line: line) XCTAssertEqual(expectedTimestmap, retrievedTimestamp, file: file, line: line) default: @@ -59,7 +59,7 @@ extension FeedStoreSpecs where Self: XCTestCase { func expect( _ sut: FeedStore, - toRetrieveTwice expectedResult: RetrieveCachedFeedResult, + toRetrieveTwice expectedResult: FeedStore.RetrievalResult, file: StaticString = #file, line: UInt = #line ) { @@ -74,7 +74,7 @@ extension FeedStoreSpecs where Self: XCTestCase { file: StaticString = #file, line: UInt = #line ) { - expect(sut, toRetrieve: .empty) + expect(sut, toRetrieve: .success(.empty)) } func assertThatRetrieveHasNoSideEffectsOnEmptyCache( @@ -82,7 +82,7 @@ extension FeedStoreSpecs where Self: XCTestCase { file: StaticString = #file, line: UInt = #line ) { - expect(sut, toRetrieveTwice: .empty) + expect(sut, toRetrieveTwice: .success(.empty)) } func assertThatRetrieveDeliversFoundValuesOnNonEmptyCache( @@ -94,7 +94,7 @@ extension FeedStoreSpecs where Self: XCTestCase { let timestamp = Date() insert((feed, timestamp), to: sut) - expect(sut, toRetrieve: .found(feed: feed, timestamp: timestamp)) + expect(sut, toRetrieve: .success(.found(feed: feed, timestamp: timestamp))) } func assertThatRetrieveHasNoSideEffectsOnNonEmptyCache( @@ -106,7 +106,7 @@ extension FeedStoreSpecs where Self: XCTestCase { let timestamp = Date() insert((feed, timestamp), to: sut) - expect(sut, toRetrieveTwice: .found(feed: feed, timestamp: timestamp)) + expect(sut, toRetrieveTwice: .success(.found(feed: feed, timestamp: timestamp))) } } @@ -138,7 +138,7 @@ extension FeedStoreSpecs where Self: XCTestCase { let latestInsertionError = insert((latestFeed, latesTimestamp), to: sut) XCTAssertNil(latestInsertionError, "Expected to override cache succesfully") - expect(sut, toRetrieve: .found(feed: latestFeed, timestamp: latesTimestamp)) + expect(sut, toRetrieve: .success(.found(feed: latestFeed, timestamp: latesTimestamp))) } } @@ -164,7 +164,7 @@ extension FeedStoreSpecs where Self: XCTestCase { let deletionError = deleteCache(from: sut) XCTAssertNil(deletionError, "Expected empty cache deletion to succeed") - expect(sut, toRetrieve: .empty) + expect(sut, toRetrieve: .success(.empty)) } func assertThatDeleteEmptiesPreviouslyInsertedCache( @@ -177,7 +177,7 @@ extension FeedStoreSpecs where Self: XCTestCase { let deletionError = deleteCache(from: sut) XCTAssertNil(deletionError, "Expected non-empty cache deletion to succeed") - expect(sut, toRetrieve: .empty) + expect(sut, toRetrieve: .success(.empty)) } func assertThatStoreSideEffectsRunSerially( From b0fe572274f1b96c8f9f41796a62401fb305c4e7 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 12:58:40 +0200 Subject: [PATCH 154/159] Refactor `CacheFeed`type from `enum` to `tuple` since we can represent the absence of a value wiht an `Optional` --- .../EssentialFeed/Feed Cache/FeedStore.swift | 7 ++---- .../CoreData/CoreDataFeedStore.swift | 4 ++-- .../Feed Cache/LocalFeedLoader.swift | 6 ++--- .../Feed Cache/Helpers/FeedStoreSpy.swift | 4 ++-- .../XCTestCase+FeedStoreSpecs.swift | 22 +++++++++---------- 5 files changed, 20 insertions(+), 23 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift index 03b80a3..cea0c3e 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -8,17 +8,14 @@ import Foundation -public enum CacheFeed { - case empty - case found(feed: [LocalFeedImage], timestamp: Date) -} +public typealias CacheFeed = (feed: [LocalFeedImage], timestamp: Date) public protocol FeedStore { typealias DeletionCompletion = (Error?) -> Void typealias InsertionCompletion = (Error?) -> Void - typealias RetrievalResult = Result + typealias RetrievalResult = Result typealias RetrievalCompletion = (RetrievalResult) -> Void /// The completion handler can be invoked in any thread. diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift index e2f080d..577cc22 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -25,9 +25,9 @@ public final class CoreDataFeedStore: FeedStore { do { if let cache = try ManagedCache.find(in: context) { - completion(.success(.found(feed: cache.localFeed, timestamp: cache.timestamp))) + completion(.success(CacheFeed(feed: cache.localFeed, timestamp: cache.timestamp))) } else { - completion(.success(.empty)) + completion(.success(.none)) } } catch { completion(.failure(error)) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index 2a1c387..efb9f57 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -26,8 +26,8 @@ extension LocalFeedLoader { case let .failure(error): completion(.failure(error)) - case let .success(.found(feed, timestamp)) where FeedCachePolicy.validate(timestamp, against: currentDate()): - completion(.success(feed.toModels())) + case let .success(.some(cache)) where FeedCachePolicy.validate(cache.timestamp, against: currentDate()): + completion(.success(cache.feed.toModels())) case .success: completion(.success([])) } @@ -40,7 +40,7 @@ extension LocalFeedLoader { store.retrieve { [weak self] result in guard let self else { return } switch result { - case let .success(.found(_, timestamp)) where !FeedCachePolicy.validate(timestamp, against: currentDate()): + case let .success(.some(cache)) where !FeedCachePolicy.validate(cache.timestamp, against: currentDate()): self.store.deleteCachedFeed { _ in } case .failure: self.store.deleteCachedFeed { _ in } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift index f916ccc..f602923 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift @@ -59,10 +59,10 @@ class FeedStoreSpy: FeedStore { } func completeRetrievalWithEmptyCache(at index: Int = 0) { - retrievalCompletions[index](.success(.empty)) + retrievalCompletions[index](.success(.none)) } func completeRetrieval(with feed: [LocalFeedImage], timestamp: Date, at index: Int = 0) { - retrievalCompletions[index](.success(.found(feed: feed, timestamp: timestamp))) + retrievalCompletions[index](.success(CacheFeed(feed: feed, timestamp: timestamp))) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift index f714ea8..a7c25af 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift @@ -45,11 +45,11 @@ extension FeedStoreSpecs where Self: XCTestCase { sut.retrieve { retrievedResult in exp.fulfill() switch (expectedResult, retrievedResult) { - case (.success(.empty), .success(.empty)), (.failure, .failure): + case (.success(.none), .success(.none)), (.failure, .failure): break - case let (.success(.found(expectedFeed, expectedTimestmap)), .success(.found(retrievedFeed, retrievedTimestamp))): - XCTAssertEqual(expectedFeed, retrievedFeed, file: file, line: line) - XCTAssertEqual(expectedTimestmap, retrievedTimestamp, file: file, line: line) + case let (.success(.some(expectedCache)), .success(.some(retrievedCache))): + XCTAssertEqual(expectedCache.feed, retrievedCache.feed, file: file, line: line) + XCTAssertEqual(expectedCache.timestamp, expectedCache.timestamp, file: file, line: line) default: XCTFail("Expected to retrieve \(expectedResult), but got \(retrievedResult)", file: file, line: line) } @@ -74,7 +74,7 @@ extension FeedStoreSpecs where Self: XCTestCase { file: StaticString = #file, line: UInt = #line ) { - expect(sut, toRetrieve: .success(.empty)) + expect(sut, toRetrieve: .success(.none)) } func assertThatRetrieveHasNoSideEffectsOnEmptyCache( @@ -82,7 +82,7 @@ extension FeedStoreSpecs where Self: XCTestCase { file: StaticString = #file, line: UInt = #line ) { - expect(sut, toRetrieveTwice: .success(.empty)) + expect(sut, toRetrieveTwice: .success(.none)) } func assertThatRetrieveDeliversFoundValuesOnNonEmptyCache( @@ -94,7 +94,7 @@ extension FeedStoreSpecs where Self: XCTestCase { let timestamp = Date() insert((feed, timestamp), to: sut) - expect(sut, toRetrieve: .success(.found(feed: feed, timestamp: timestamp))) + expect(sut, toRetrieve: .success(CacheFeed(feed: feed, timestamp: timestamp))) } func assertThatRetrieveHasNoSideEffectsOnNonEmptyCache( @@ -106,7 +106,7 @@ extension FeedStoreSpecs where Self: XCTestCase { let timestamp = Date() insert((feed, timestamp), to: sut) - expect(sut, toRetrieveTwice: .success(.found(feed: feed, timestamp: timestamp))) + expect(sut, toRetrieveTwice: .success(CacheFeed(feed: feed, timestamp: timestamp))) } } @@ -138,7 +138,7 @@ extension FeedStoreSpecs where Self: XCTestCase { let latestInsertionError = insert((latestFeed, latesTimestamp), to: sut) XCTAssertNil(latestInsertionError, "Expected to override cache succesfully") - expect(sut, toRetrieve: .success(.found(feed: latestFeed, timestamp: latesTimestamp))) + expect(sut, toRetrieve: .success(CacheFeed (feed: latestFeed, timestamp: latesTimestamp))) } } @@ -164,7 +164,7 @@ extension FeedStoreSpecs where Self: XCTestCase { let deletionError = deleteCache(from: sut) XCTAssertNil(deletionError, "Expected empty cache deletion to succeed") - expect(sut, toRetrieve: .success(.empty)) + expect(sut, toRetrieve: .success(.none)) } func assertThatDeleteEmptiesPreviouslyInsertedCache( @@ -177,7 +177,7 @@ extension FeedStoreSpecs where Self: XCTestCase { let deletionError = deleteCache(from: sut) XCTAssertNil(deletionError, "Expected non-empty cache deletion to succeed") - expect(sut, toRetrieve: .success(.empty)) + expect(sut, toRetrieve: .success(.none)) } func assertThatStoreSideEffectsRunSerially( From 3391786467a04fe4b0390cb12bcfd675a0bb772c Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 13:03:16 +0200 Subject: [PATCH 155/159] Add typealiases for `FeedStore.DeletionResult` and `FeedStore.InsertionResult` --- EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift index cea0c3e..a9142bc 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -11,9 +11,11 @@ import Foundation public typealias CacheFeed = (feed: [LocalFeedImage], timestamp: Date) public protocol FeedStore { - typealias DeletionCompletion = (Error?) -> Void - typealias InsertionCompletion = (Error?) -> Void + typealias DeletionResult = Error? + typealias DeletionCompletion = (DeletionResult) -> Void + typealias InsertionResult = Error? + typealias InsertionCompletion = (InsertionResult) -> Void typealias RetrievalResult = Result typealias RetrievalCompletion = (RetrievalResult) -> Void From 97189d0a752e15a14702e923b8bc5012815fd36c Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 13:12:29 +0200 Subject: [PATCH 156/159] Replace occurencies of `Error?`for representing operation success/failure wiht `Result` --- .../EssentialFeed/Feed Cache/FeedStore.swift | 4 ++-- .../CoreData/CoreDataFeedStore.swift | 8 ++++---- .../Feed Cache/LocalFeedLoader.swift | 11 ++++++----- .../EssentialFeedCacheIntegrationTests.swift | 6 +++++- .../Feed Cache/CacheFeedUseCaseTests.swift | 8 ++++++-- .../Feed Cache/Helpers/FeedStoreSpy.swift | 8 ++++---- .../Feed Cache/XCTestCase+FeedStoreSpecs.swift | 16 ++++++++++++---- 7 files changed, 39 insertions(+), 22 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift index a9142bc..0c78bec 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -11,10 +11,10 @@ import Foundation public typealias CacheFeed = (feed: [LocalFeedImage], timestamp: Date) public protocol FeedStore { - typealias DeletionResult = Error? + typealias DeletionResult = Result typealias DeletionCompletion = (DeletionResult) -> Void - typealias InsertionResult = Error? + typealias InsertionResult = Result typealias InsertionCompletion = (InsertionResult) -> Void typealias RetrievalResult = Result diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift index 577cc22..6a9261c 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -45,9 +45,9 @@ public final class CoreDataFeedStore: FeedStore { in: context ) try context.save() - completion(nil) + completion(.success(())) } catch { - completion(error) + completion(.failure(error)) } } @@ -60,9 +60,9 @@ public final class CoreDataFeedStore: FeedStore { try ManagedCache.find(in: context) .map(context.delete) .map(context.save) - completion(nil) + completion(.success(())) } catch { - completion(error) + completion(.failure(error)) } } } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift index efb9f57..ba75315 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -51,14 +51,15 @@ extension LocalFeedLoader { } extension LocalFeedLoader { - public typealias SaveResult = Error? + public typealias SaveResult = Result public func save(_ feed: [FeedImage], completion: @escaping (SaveResult) -> Void) { - store.deleteCachedFeed { [weak self] error in + store.deleteCachedFeed { [weak self] deletionResult in guard let self else { return } - if let cacheDeletionError = error { - completion(cacheDeletionError) - } else { + switch deletionResult { + case .success: self.cache(feed, with: completion) + case let .failure(error): + completion(.failure(error)) } } } diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift index 18e2f2c..29541f3 100644 --- a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift @@ -104,8 +104,12 @@ final class EssentialFeedCacheIntegrationTests: XCTestCase { line: UInt = #line ) { let exp = expectation(description: "Wait for save completion") - sut.save(feed) { error in + sut.save(feed) { result in + switch result { + case let .failure(error): XCTAssertNil(error, "Unexpected error: \(String(describing: error))", file: file, line: line) + default: break + } exp.fulfill() } wait(for: [exp], timeout: 1.0) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift index 1319e81..3e15850 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -111,9 +111,13 @@ class CacheFeedUseCaseTests: XCTestCase { ) { let exp = expectation(description: "Wait for save completion") var receivedError: Error? - sut.save(uniqueImageFeed().models) { error in - receivedError = error + sut.save(uniqueImageFeed().models) { result in exp.fulfill() + switch result { + case let .failure(error): + receivedError = error + default: break + } } action() diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift index f602923..083ce85 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift @@ -28,11 +28,11 @@ class FeedStoreSpy: FeedStore { } func completeDeletion(with error: Error, at index: Int = 0) { - deletionCompletions[index](error) + deletionCompletions[index](.failure(error)) } func completeDeletionSuccesfully(at index: Int = 0) { - deletionCompletions[index](nil) + deletionCompletions[index](.success(())) } @@ -42,11 +42,11 @@ class FeedStoreSpy: FeedStore { } func completeInsertion(with error: Error, at index: Int = 0) { - insertionCompletions[index](error) + insertionCompletions[index](.failure(error)) } func completeInsertionSuccesfully(at index: Int = 0) { - insertionCompletions[index](nil) + insertionCompletions[index](.success(())) } func retrieve(completion: @escaping RetrievalCompletion) { diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift index a7c25af..3f59ed4 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift @@ -12,8 +12,12 @@ extension FeedStoreSpecs where Self: XCTestCase { func insert(_ cache: (feed: [LocalFeedImage], timestamp: Date), to sut: FeedStore) -> Error? { let exp = expectation(description: "Wait for cache insertion") var insertionError: Error? - sut.insert(cache.feed, timestamp: cache.timestamp) { error in - insertionError = error + sut.insert(cache.feed, timestamp: cache.timestamp) { result in + switch result { + case let .failure(error): + insertionError = error + default: break + } exp.fulfill() } @@ -25,9 +29,13 @@ extension FeedStoreSpecs where Self: XCTestCase { func deleteCache(from sut: FeedStore) -> Error? { let exp = expectation(description: "Wait for cache deletion") var receivedError: Error? - sut.deleteCachedFeed { + sut.deleteCachedFeed { result in exp.fulfill() - receivedError = $0 + switch result { + case let .failure(error): + receivedError = error + default: break + } } wait(for: [exp], timeout: 1.0) From 40c4569283c4b3b1bfd8078977084539aefe4ec6 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 13:19:37 +0200 Subject: [PATCH 157/159] Simplify `CoreDataFeedStore` completion code with the new `Result` APIs --- .../CoreData/CoreDataFeedStore.swift | 54 +++++++++---------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift index 6a9261c..611007f 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -22,33 +22,29 @@ public final class CoreDataFeedStore: FeedStore { public func retrieve(completion: @escaping RetrievalCompletion) { perform { context in - - do { - if let cache = try ManagedCache.find(in: context) { - completion(.success(CacheFeed(feed: cache.localFeed, timestamp: cache.timestamp))) - } else { - completion(.success(.none)) + completion( + Result { + try ManagedCache.find(in: context).map { + CacheFeed(feed: $0.localFeed, timestamp: $0.timestamp) + } } - } catch { - completion(.failure(error)) - } + ) } } public func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { perform { context in - do { - let managedCache = try ManagedCache.newUniqueInstance(in: context) - managedCache.timestamp = timestamp - managedCache.feed = ManagedFeedImage.images( - from: feed, - in: context - ) - try context.save() - completion(.success(())) - } catch { - completion(.failure(error)) - } + completion( + Result { + let managedCache = try ManagedCache.newUniqueInstance(in: context) + managedCache.timestamp = timestamp + managedCache.feed = ManagedFeedImage.images( + from: feed, + in: context + ) + try context.save() + } + ) } } @@ -56,14 +52,14 @@ public final class CoreDataFeedStore: FeedStore { public func deleteCachedFeed(completion: @escaping DeletionCompletion) { perform { context in - do { - try ManagedCache.find(in: context) - .map(context.delete) - .map(context.save) - completion(.success(())) - } catch { - completion(.failure(error)) - } + completion( + Result { + try ManagedCache.find(in: context) + .map(context.delete) + .map(context.save) + } + + ) } } From 04d9704f87c34c78222c89b067b759377fbbb50d Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 13:21:23 +0200 Subject: [PATCH 158/159] Simplify `get` method of `URLSessionHTTPClient` by leveraging native `Result` initializer --- .../FeedApi/URLSessionHTTPClient.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/EssentialFeed/EssentialFeed/FeedApi/URLSessionHTTPClient.swift b/EssentialFeed/EssentialFeed/FeedApi/URLSessionHTTPClient.swift index e97d481..7860d0b 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/URLSessionHTTPClient.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/URLSessionHTTPClient.swift @@ -17,13 +17,17 @@ public class URLSessionHTTPClient: HTTPClient { public func get(from url: URL, completion: @escaping (HTTPClient.Result) -> Void) { session.dataTask(with: url) { data, response, error in - if let error { - completion(.failure(error)) - } else if let data, let response = response as? HTTPURLResponse { - completion(.success((data, response))) - } else { - completion(.failure(UnexpectedValuesRepresentation())) - } + completion( + Result { + if let error { + throw error + } else if let data, let response = response as? HTTPURLResponse { + return (data, response) + } else { + throw UnexpectedValuesRepresentation() + } + } + ) } .resume() } From 2b975bb76da08bd784de2227d686aef004b3ef40 Mon Sep 17 00:00:00 2001 From: Cristian Rojas Date: Wed, 9 Apr 2025 13:25:23 +0200 Subject: [PATCH 159/159] Update CI's xcode version to 16.2 --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 7bf4563..a66f1d2 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode - run: sudo xcode-select -switch /Applications/Xcode_16.0.app + run: sudo xcode-select -switch /Applications/Xcode_16.2.app - name: Xcode version run: /usr/bin/xcodebuild -version