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 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/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 0def712..0be8578 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -8,9 +8,28 @@ /* 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 */; }; + 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 */; }; + 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 */; }; + 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 /* 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 */; }; + 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 */ @@ -21,6 +40,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 */; @@ -35,16 +61,44 @@ 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 /* 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 = ""; }; + 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 */ - 40B002502CF9F0420058D3E0 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + 40412A562DA678BE004677C4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - "Helpers/XCTestCase+MemoryLeakTrackingHelper.swift", + SharedTestHelpers.swift, + "XCTestCase+MemoryLeakTrackingHelper.swift", + ); + target = 40412A492DA67465004677C4 /* EssentialFeedCacheIntegrationTests */; + }; + 40B975402D9E7CB2009652B5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + SharedTestHelpers.swift, + "XCTestCase+MemoryLeakTrackingHelper.swift", ); target = 40B002442CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */; }; @@ -53,6 +107,7 @@ membershipExceptions = ( FeedItemsMapper.swift, HTTPClient.swift, + RemoteFeedItem.swift, RemoteFeedLoader.swift, URLSessionHTTPClient.swift, ); @@ -61,8 +116,10 @@ /* 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 = ""; }; + 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 */, 40412A562DA678BE004677C4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Helpers; sourceTree = ""; }; 40C57D7A2CF7C16E00518522 /* FeedApi */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (40C57D7D2CF7C19100518522 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = FeedApi; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -82,6 +139,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 40412A472DA67465004677C4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 40412A4E2DA67465004677C4 /* EssentialFeed.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 40B002422CF9E9DB0058D3E0 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -96,9 +161,11 @@ 080EDEE721B6DA7E00813479 = { isa = PBXGroup; children = ( + 40412A542DA6750C004677C4 /* EssentialFeedCacheIntegrationTests.xctestplan */, 080EDEF321B6DA7E00813479 /* EssentialFeed */, 080EDEFE21B6DA7E00813479 /* EssentialFeedTests */, 40B002462CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */, + 40412A4B2DA67465004677C4 /* EssentialFeedCacheIntegrationTests */, 080EDEF221B6DA7E00813479 /* Products */, ); sourceTree = ""; @@ -109,6 +176,7 @@ 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */, 080EDEFA21B6DA7E00813479 /* EssentialFeedTests.xctest */, 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */, + 40412A4A2DA67465004677C4 /* EssentialFeedCacheIntegrationTests.xctest */, ); name = Products; sourceTree = ""; @@ -116,6 +184,7 @@ 080EDEF321B6DA7E00813479 /* EssentialFeed */ = { isa = PBXGroup; children = ( + 40B975412D9E9FB7009652B5 /* Feed Cache */, 40C57D7A2CF7C16E00518522 /* FeedApi */, 080EDEF521B6DA7E00813479 /* Info.plist */, 080EDF1021B6DFA200813479 /* Feed Feature */, @@ -126,6 +195,8 @@ 080EDEFE21B6DA7E00813479 /* EssentialFeedTests */ = { isa = PBXGroup; children = ( + 40B9753D2D9E7CB2009652B5 /* Helpers */, + 40B975392D9E7AD3009652B5 /* Feed Cache */, 40124C322CF8BDD5008BBDB6 /* Feed Api */, 080EDF0121B6DA7E00813479 /* Info.plist */, ); @@ -135,12 +206,67 @@ 080EDF1021B6DFA200813479 /* Feed Feature */ = { isa = PBXGroup; children = ( - 080EDF0B21B6DAE800813479 /* FeedItem.swift */, + 080EDF0B21B6DAE800813479 /* FeedImage.swift */, 080EDF0D21B6DCB600813479 /* FeedLoader.swift */, ); path = "Feed Feature"; sourceTree = ""; }; + 40412A1A2DA67130004677C4 /* Infrastructure */ = { + isa = PBXGroup; + children = ( + 40412A1B2DA67135004677C4 /* CoreData */, + ); + path = Infrastructure; + sourceTree = ""; + }; + 40412A1B2DA67135004677C4 /* CoreData */ = { + isa = PBXGroup; + children = ( + 40412A202DA6723E004677C4 /* ManagedFeedImage.swift */, + 40412A1E2DA67223004677C4 /* ManagedCache.swift */, + 40412A1C2DA6719B004677C4 /* CoreDataHelpers.swift */, + 407F4FD22DA530FF0070F56E /* CoreDataFeedStore.swift */, + 40412A152DA65403004677C4 /* FeedStore.xcdatamodeld */, + ); + path = CoreData; + sourceTree = ""; + }; + 40B975392D9E7AD3009652B5 /* Feed Cache */ = { + isa = PBXGroup; + children = ( + 407F4FD02DA5307B0070F56E /* CoreDataFeedStoreSpecs.swift */, + 407F4FCB2DA403260070F56E /* XCTestCase+FeedStoreSpecs.swift */, + 407F4FC92DA402D30070F56E /* FeedStoreSpecs.swift */, + 40B9754C2D9EC147009652B5 /* Helpers */, + 40B9754A2D9EC05C009652B5 /* LoadFeedFromCacheUseCaseTests.swift */, + 40B9753A2D9E7ADB009652B5 /* CacheFeedUseCaseTests.swift */, + 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */, + ); + path = "Feed Cache"; + sourceTree = ""; + }; + 40B975412D9E9FB7009652B5 /* Feed Cache */ = { + isa = PBXGroup; + children = ( + 40412A1A2DA67130004677C4 /* Infrastructure */, + 40B975422D9E9FC4009652B5 /* LocalFeedLoader.swift */, + 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */, + 40B975442D9EA01D009652B5 /* FeedStore.swift */, + 40B975482D9EA7A2009652B5 /* LocalFeedImage.swift */, + ); + path = "Feed Cache"; + sourceTree = ""; + }; + 40B9754C2D9EC147009652B5 /* Helpers */ = { + isa = PBXGroup; + children = ( + 40B9754D2D9EC15A009652B5 /* FeedStoreSpy.swift */, + 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */, + ); + path = Helpers; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -187,12 +313,36 @@ ); fileSystemSynchronizedGroups = ( 40124C322CF8BDD5008BBDB6 /* Feed Api */, + 40B9753D2D9E7CB2009652B5 /* Helpers */, ); name = EssentialFeedTests; productName = EssentialFeedTests; 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" */; @@ -223,7 +373,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1600; + LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 1600; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -235,6 +385,9 @@ CreatedOnToolsVersion = 10.1; LastSwiftMigration = 1540; }; + 40412A492DA67465004677C4 = { + CreatedOnToolsVersion = 16.2; + }; 40B002442CF9E9DB0058D3E0 = { CreatedOnToolsVersion = 16.0; }; @@ -256,6 +409,7 @@ 080EDEF021B6DA7E00813479 /* EssentialFeed */, 080EDEF921B6DA7E00813479 /* EssentialFeedTests */, 40B002442CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */, + 40412A492DA67465004677C4 /* EssentialFeedCacheIntegrationTests */, ); }; /* End PBXProject section */ @@ -275,6 +429,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 40412A482DA67465004677C4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 40B002432CF9E9DB0058D3E0 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -289,8 +450,17 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 40412A1D2DA6719E004677C4 /* CoreDataHelpers.swift in Sources */, + 40412A172DA65403004677C4 /* FeedStore.xcdatamodeld in Sources */, 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */, - 080EDF0C21B6DAE800813479 /* FeedItem.swift in Sources */, + 080EDF0C21B6DAE800813479 /* FeedImage.swift in Sources */, + 40B975492D9EA7A2009652B5 /* LocalFeedImage.swift in Sources */, + 407F4FAD2D9FEBC50070F56E /* FeedCachePolicy.swift in Sources */, + 40B975452D9EA01D009652B5 /* FeedStore.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; }; @@ -298,6 +468,22 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 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 */, + 407F4FA72D9FDB810070F56E /* FeedCacheTestHelpers.swift in Sources */, + 407F4FCC2DA403330070F56E /* XCTestCase+FeedStoreSpecs.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 40412A462DA67465004677C4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 40412A572DA67A9F004677C4 /* FeedCacheTestHelpers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -316,6 +502,11 @@ target = 080EDEF021B6DA7E00813479 /* EssentialFeed */; targetProxy = 080EDEFC21B6DA7E00813479 /* PBXContainerItemProxy */; }; + 40412A502DA67465004677C4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 080EDEF021B6DA7E00813479 /* EssentialFeed */; + targetProxy = 40412A4F2DA67465004677C4 /* PBXContainerItemProxy */; + }; 40B0024B2CF9E9DB0058D3E0 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 080EDEF021B6DA7E00813479 /* EssentialFeed */; @@ -559,6 +750,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 = { @@ -628,6 +858,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 = ( @@ -638,6 +877,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.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"> + + + + diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme new file mode 100644 index 0000000..e950f52 --- /dev/null +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.swift new file mode 100644 index 0000000..8df61fb --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/CoreDataStore.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/FeedCachePolicy.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedCachePolicy.swift new file mode 100644 index 0000000..73dd51d --- /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 + +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/FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift new file mode 100644 index 0000000..0c78bec --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/FeedStore.swift @@ -0,0 +1,36 @@ +// +// FeedStore.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 3/4/25. +// + +import Foundation + + +public typealias CacheFeed = (feed: [LocalFeedImage], timestamp: Date) + +public protocol FeedStore { + typealias DeletionResult = Result + typealias DeletionCompletion = (DeletionResult) -> Void + + typealias InsertionResult = Result + typealias InsertionCompletion = (InsertionResult) -> Void + + typealias RetrievalResult = Result + typealias RetrievalCompletion = (RetrievalResult) -> Void + + /// The completion handler can be invoked in any thread. + /// Clients are responsible to dispatch to 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) +} + + diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift new file mode 100644 index 0000000..611007f --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -0,0 +1,71 @@ +// +// 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) { + perform { context in + completion( + Result { + try ManagedCache.find(in: context).map { + CacheFeed(feed: $0.localFeed, timestamp: $0.timestamp) + } + } + ) + } + } + + public func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) { + perform { context in + completion( + Result { + let managedCache = try ManagedCache.newUniqueInstance(in: context) + managedCache.timestamp = timestamp + managedCache.feed = ManagedFeedImage.images( + from: feed, + in: context + ) + try context.save() + } + ) + } + + } + + + public func deleteCachedFeed(completion: @escaping DeletionCompletion) { + perform { context in + completion( + Result { + try ManagedCache.find(in: context) + .map(context.delete) + .map(context.save) + } + + ) + } + } + + private func perform(_ action: @escaping (NSManagedObjectContext) -> Void) { + context.perform { [context] in + action(context) + } + } +} 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) + } + } +} diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/FeedStore.xcdatamodeld/FeedStore.xcdatamodel/contents b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/FeedStore.xcdatamodeld/FeedStore.xcdatamodel/contents new file mode 100644 index 0000000..7182245 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/FeedStore.xcdatamodeld/FeedStore.xcdatamodel/contents @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file 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..d712a19 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedCache.swift @@ -0,0 +1,31 @@ +// +// 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 +} + +extension ManagedCache { + 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..03bda54 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/ManagedFeedImage.swift @@ -0,0 +1,41 @@ +// +// 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 +} + +extension ManagedFeedImage { + + 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/LocalFeedImage.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift new file mode 100644 index 0000000..b94cfe9 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedImage.swift @@ -0,0 +1,28 @@ +// +// LocalFeedImage.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 3/4/25. +// + + +import Foundation + +public struct LocalFeedImage: Equatable { + + public let id: UUID + public let description: String? + public let location: String? + public let url: URL + + public init( + id: UUID, + description: String?, + location: String?, + url: URL) { + self.id = id + self.description = description + self.location = location + self.url = url + } +} diff --git a/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift new file mode 100644 index 0000000..ba75315 --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/LocalFeedLoader.swift @@ -0,0 +1,99 @@ +// +// LocalFeedLoader.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 3/4/25. +// +import Foundation + + + +public final class LocalFeedLoader: FeedLoader { + private let store: FeedStore + private let currentDate: () -> Date + public init(store: FeedStore, currentDate: @escaping () -> Date) { + self.store = store + self.currentDate = currentDate + } +} + +extension LocalFeedLoader { + public typealias LoadResult = FeedLoader.Result + public func load(completion: @escaping (LoadResult) -> Void) { + store.retrieve { [weak self] result in + guard let self else { return } + switch result { + case let .failure(error): + completion(.failure(error)) + + case let .success(.some(cache)) where FeedCachePolicy.validate(cache.timestamp, against: currentDate()): + completion(.success(cache.feed.toModels())) + case .success: + completion(.success([])) + } + } + } +} + +extension LocalFeedLoader { + public func validateCache() { + store.retrieve { [weak self] result in + guard let self else { return } + switch result { + case let .success(.some(cache)) where !FeedCachePolicy.validate(cache.timestamp, against: currentDate()): + self.store.deleteCachedFeed { _ in } + case .failure: + self.store.deleteCachedFeed { _ in } + case .success: break + } + } + } +} + +extension LocalFeedLoader { + public typealias SaveResult = Result + public func save(_ feed: [FeedImage], completion: @escaping (SaveResult) -> Void) { + store.deleteCachedFeed { [weak self] deletionResult in + guard let self else { return } + switch deletionResult { + case .success: + self.cache(feed, with: completion) + case let .failure(error): + completion(.failure(error)) + } + } + } + + private func cache(_ feed: [FeedImage], with completion: @escaping (SaveResult) -> Void) { + store.insert(feed.toLocal(), timestamp: currentDate()) { [weak self] error in + guard self != nil else { return } + completion(error) + } + } +} + +private extension Array where Element == FeedImage { + func toLocal() -> [LocalFeedImage] { + return map { + LocalFeedImage( + id: $0.id, + description: $0.description, + location: $0.location, + url: $0.url + ) + } + } +} + +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/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..c447b7e 100644 --- a/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift +++ b/EssentialFeed/EssentialFeed/Feed Feature/FeedLoader.swift @@ -4,11 +4,8 @@ import Foundation -public enum LoadFeedResult { - case success([FeedItem]) - case failure(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/FeedItemsMapper.swift b/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift index 22505bd..5ddec9c 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/FeedItemsMapper.swift @@ -6,42 +6,24 @@ // import Foundation -internal enum FeedItemsMapper { + + +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 { + 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/HTTPClient.swift b/EssentialFeed/EssentialFeed/FeedApi/HTTPClient.swift index dc7f988..068e1c3 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/HTTPClient.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/HTTPClient.swift @@ -6,11 +6,11 @@ // import Foundation -public enum HTTPClientResult { - case success(Data, HTTPURLResponse) - case failure(Error) -} public protocol HTTPClient { - func get(from url: URL, completion: @escaping (HTTPClientResult) -> Void) + + 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 (Result) -> Void) } diff --git a/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedItem.swift b/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedItem.swift new file mode 100644 index 0000000..6589a30 --- /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 + +struct RemoteFeedItem: Decodable { + let id: UUID + let description: String? + let location: String? + let image: URL +} diff --git a/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift b/EssentialFeed/EssentialFeed/FeedApi/RemoteFeedLoader.swift index eccd2b0..b198fa6 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 @@ -27,12 +27,34 @@ 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): - completion(FeedItemsMapper.map(data, from: response)) + case let .success((data, 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() -> [FeedImage] { + return map { + FeedImage( + id: $0.id, + description: $0.description, + location: $0.location, + url: $0.image + ) + } + } +} diff --git a/EssentialFeed/EssentialFeed/FeedApi/URLSessionHTTPClient.swift b/EssentialFeed/EssentialFeed/FeedApi/URLSessionHTTPClient.swift index a8b0f85..7860d0b 100644 --- a/EssentialFeed/EssentialFeed/FeedApi/URLSessionHTTPClient.swift +++ b/EssentialFeed/EssentialFeed/FeedApi/URLSessionHTTPClient.swift @@ -15,15 +15,19 @@ 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)) - } 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() } diff --git a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift index 9bc4bb2..7d0402a 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: @@ -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 @@ -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/EssentialFeedCacheIntegrationTests.xctestplan b/EssentialFeed/EssentialFeedCacheIntegrationTests.xctestplan new file mode 100644 index 0000000..3016214 --- /dev/null +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests.xctestplan @@ -0,0 +1,34 @@ +{ + "configurations" : [ + { + "id" : "278ED14E-66BA-4B7E-8D9C-11AD8033655C", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "080EDEF021B6DA7E00813479", + "name" : "EssentialFeed" + } + ] + }, + "testExecutionOrdering" : "random" + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "40412A492DA67465004677C4", + "name" : "EssentialFeedCacheIntegrationTests" + } + } + ], + "version" : 1 +} diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift new file mode 100644 index 0000000..29541f3 --- /dev/null +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift @@ -0,0 +1,137 @@ +// +// EssentialFeedCacheIntegrationTests.swift +// EssentialFeedCacheIntegrationTests +// +// Created by Cristian Felipe Patiño Rojas on 9/4/25. +// + +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() + expect(sut, toLoad: []) + } + + func test_load_deliversItemsSavedOnASeparatedInstance() { + let sutToPerformSave = makeSUT() + let sutToPerformLoad = makeSUT() + let feed = uniqueImageFeed().models + + save(feed, with: sutToPerformSave) + + 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 + + save(firstFeed, with: sutToPerformFirstSave) + save(latestFeed, with: sutToPerformLastSave) + + expect(sutToPerformLoad, toLoad: latestFeed) + } + + // 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 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 save( + _ feed: [FeedImage], + with sut: LocalFeedLoader, + file: StaticString = #file, + line: UInt = #line + ) { + let exp = expectation(description: "Wait for save completion") + 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) + } + + private func testSpecificStoreURL() -> URL { + return cachesDirectory().appendingPathComponent("\(type(of: self)).store") + } + + 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()) + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Api/RemoteFeedLoaderTests.swift b/EssentialFeed/EssentialFeedTests/Feed Api/LoadFeedFromRemoteUseCaseTests.swift similarity index 95% rename from EssentialFeed/EssentialFeedTests/Feed Api/RemoteFeedLoaderTests.swift rename to EssentialFeed/EssentialFeedTests/Feed Api/LoadFeedFromRemoteUseCaseTests.swift index 7a7fea1..5cc37d1 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() @@ -122,12 +122,12 @@ class RemoteFeedLoaderTests: 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, @@ -174,9 +174,9 @@ class RemoteFeedLoaderTests: 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 RemoteFeedLoaderTests: 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 4868054..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 @@ -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 new file mode 100644 index 0000000..3e15850 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CacheFeedUseCaseTests.swift @@ -0,0 +1,133 @@ +// +// CacheFeedUseCaseTests.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 3/4/25. +// + +import EssentialFeed +import XCTest + + +class CacheFeedUseCaseTests: XCTestCase { + + func test_doesNotMessageStoreUponCreation() { + let (_, store) = makeSUT() + XCTAssertEqual(store.receivedMessages, []) + } + + func test_save_requestsCacheDeletion() { + let (sut, store) = makeSUT() + sut.save(uniqueImageFeed().models) { _ in } + XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) + } + + func test_save_doesNotRequestCacheInsertionOnDeletionError() { + let (sut, store) = makeSUT() + let deletionError = anyNSError() + sut.save(uniqueImageFeed().models) { _ in } + store.completeDeletion(with: deletionError) + XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed]) + } + + func test_save_requestsNewCacheInsertionWithTimestampOnSuccesfulDeletion() { + let timestamp = Date() + let (sut, store) = makeSUT(currentDate: { timestamp }) + let feed = uniqueImageFeed() + let localFeed = feed.local + sut.save(feed.models) { _ in } + store.completeDeletionSuccesfully() + + XCTAssertEqual(store.receivedMessages, [.deleteCachedFeed, .insert(localFeed, timestamp)]) + } + + func test_save_failsOnDeletionError() { + let (sut, store) = makeSUT() + let deletionError = anyNSError() + expect(sut, toCompleteWithError: deletionError, when: { + store.completeDeletion(with: deletionError) + }) + } + + func test_save_failsOnInsertionError() { + + let (sut, store) = makeSUT() + let insertionError = anyNSError() + expect(sut, toCompleteWithError: insertionError, when: { + store.completeDeletionSuccesfully() + store.completeInsertion(with: insertionError) + }) + } + + func test_save_succeedsOnSuccesfulCacheInsertion() { + let (sut, store) = makeSUT() + expect(sut, toCompleteWithError: nil, when: { + store.completeDeletionSuccesfully() + store.completeInsertionSuccesfully() + }) + } + + func test_save_doesNotDeliverDeletionErrorAfterSUTInstanceHasBeenDeallocated() { + let store = FeedStoreSpy() + var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) + var receivedResults = [LocalFeedLoader.SaveResult]() + sut?.save(uniqueImageFeed().models) { receivedResults.append($0) } + sut = nil + store.completeDeletion(with: anyNSError()) + XCTAssertTrue(receivedResults.isEmpty) + } + + func test_save_doesNotDeliverInsertionErrorAfterSUTInstanceHasBeenDeallocated() { + let store = FeedStoreSpy() + var sut: LocalFeedLoader? = LocalFeedLoader(store: store, currentDate: Date.init) + var receivedResults = [LocalFeedLoader.SaveResult]() + sut?.save(uniqueImageFeed().models) { receivedResults.append($0) } + store.completeDeletionSuccesfully() + sut = nil + store.completeInsertion(with: anyNSError()) + XCTAssertTrue(receivedResults.isEmpty) + } + + // 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 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(uniqueImageFeed().models) { result in + exp.fulfill() + switch result { + case let .failure(error): + receivedError = error + default: break + } + } + + action() + + wait(for: [exp], timeout: 1.0) + XCTAssertEqual( + receivedError as NSError?, + expectedError, + file: file, + line: line + ) + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift new file mode 100644 index 0000000..7f7ac9e --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -0,0 +1,83 @@ +// +// CoreDataFeedStoreSpecs.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 8/4/25. +// + +import XCTest +import EssentialFeed + +class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { + func test_retrieve_deliversEmptyOnEmptyCache() { + let sut = makeSUT() + assertThatRetrieveDeliversEmptyOnEmptyCache(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_deliversNoErrorOnEmptyCache() { + let sut = makeSUT() + assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) + } + + func test_delete_deliversNoErrorOnNonEmptyCache() { + let sut = makeSUT() + assertThatDeleteDeliversNoErrorOnNonEmptyCache(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) + } + + // MARK: - Helpers + 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! CoreDataFeedStore( + storeURL: storeURL, + bundle: storeBundle + ) + trackForMemoryLeaks(sut, file: file, line: line) + return sut + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift new file mode 100644 index 0000000..625a31a --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedStoreSpecs.swift @@ -0,0 +1,39 @@ +// +// 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_deliversNoErrorOnEmptyCache() + func test_insert_deliversNoErrorOnNonEmptyCache() + func test_insert_overridesPreviouslyInsertedCacheValues() + + func test_delete_deliversNoErrorOnEmptyCache() + func test_delete_deliversNoErrorOnNonEmptyCache() + 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() +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift new file mode 100644 index 0000000..18ca22c --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedCacheTestHelpers.swift @@ -0,0 +1,45 @@ +// +// 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 minusFeedCacheMaxAge() -> Date { + 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)! + } +} + +extension Date { + func adding(seconds: TimeInterval) -> Date { + return self + seconds + } +} + diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift new file mode 100644 index 0000000..083ce85 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/Helpers/FeedStoreSpy.swift @@ -0,0 +1,68 @@ +// +// 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 var retrievalCompletions = [RetrievalCompletion]() + + 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](.failure(error)) + } + + func completeDeletionSuccesfully(at index: Int = 0) { + deletionCompletions[index](.success(())) + } + + + 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](.failure(error)) + } + + func completeInsertionSuccesfully(at index: Int = 0) { + insertionCompletions[index](.success(())) + } + + func retrieve(completion: @escaping RetrievalCompletion) { + retrievalCompletions.append(completion) + receivedMessages.append(.retrieve) + } + + func completeRetrieval(with error: Error, at index: Int = 0) { + retrievalCompletions[index](.failure(error)) + } + + func completeRetrievalWithEmptyCache(at index: Int = 0) { + retrievalCompletions[index](.success(.none)) + } + + func completeRetrieval(with feed: [LocalFeedImage], timestamp: Date, at index: Int = 0) { + retrievalCompletions[index](.success(CacheFeed(feed: feed, timestamp: timestamp))) + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift new file mode 100644 index 0000000..43ce6af --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/LoadFeedFromCacheUseCaseTests.swift @@ -0,0 +1,190 @@ +// +// 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, []) + } + + func test_load_requestsCacheRetrieval() { + let (sut, store) = makeSUT() + sut.load() { _ in } + XCTAssertEqual(store.receivedMessages, [.retrieve]) + } + + func test_load_failsOnRetrievalError() { + let (sut, store) = makeSUT() + let retrievalError = anyNSError() + + expect(sut, toCompleteWith: .failure(retrievalError), when: { + store.completeRetrieval(with: retrievalError) + }) + } + + func test_load_deliversNoImagesOnEmptyCache() { + let (sut, store) = makeSUT() + + expect(sut, toCompleteWith: .success([]), when: { + store.completeRetrievalWithEmptyCache() + }) + } + + func test_load_deliversCachedImagesOnNonExpiredCache() { + let feed = uniqueImageFeed() + let fixedCurrentDate = Date() + 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: nonExpiredTimestamp) + }) + } + + func test_load_deliversNoImagesOnCacheExpiration() { + let feed = uniqueImageFeed() + let fixedCurrentDate = Date() + let expirationTimestamp = fixedCurrentDate.minusFeedCacheMaxAge() + + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + expect(sut, toCompleteWith: .success([]), when: { + store.completeRetrieval(with: feed.local, timestamp: expirationTimestamp) + }) + } + + func test_load_deliversNoImagesOnExpiredCache() { + let feed = uniqueImageFeed() + let fixedCurrentDate = Date() + let expiredTimestamp = fixedCurrentDate + .minusFeedCacheMaxAge() + .adding(seconds: -1) + + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + expect(sut, toCompleteWith: .success([]), when: { + store.completeRetrieval(with: feed.local, timestamp: expiredTimestamp) + }) + } + + func test_load_hasNoSideEffectsOnRetrievalError() { + let (sut, store) = makeSUT() + sut.load { _ in } + store.completeRetrieval(with: anyNSError()) + XCTAssertEqual(store.receivedMessages, [.retrieve]) + } + + func test_load_hasNoSideEffectsOnEmptyCache() { + let (sut, store) = makeSUT() + sut.load { _ in } + store.completeRetrievalWithEmptyCache() + XCTAssertEqual(store.receivedMessages, [.retrieve]) + } + + func test_load_hasNoSideEffectsOnNonExpiredCache() { + let fixedCurrentDate = Date() + let nonExpiredTimestamp = fixedCurrentDate + .minusFeedCacheMaxAge() + .adding(seconds: 1) + + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + sut.load { _ in } + store.completeRetrieval( + with: uniqueImageFeed().local, + timestamp: nonExpiredTimestamp + ) + XCTAssertEqual(store.receivedMessages, [.retrieve]) + } + + func test_load_hasNoSideEffectsOnCacheExpiration() { + let fixedCurrentDate = Date() + let expirationTimestamp = fixedCurrentDate.minusFeedCacheMaxAge() + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + + sut.load { _ in } + store.completeRetrieval(with: uniqueImageFeed().local, timestamp: expirationTimestamp) + XCTAssertEqual(store.receivedMessages, [.retrieve]) + } + + func test_load_hasNoSideEffectsOnExpiredCache() { + let fixedCurrentDate = Date() + let expiredTimestamp = fixedCurrentDate + .minusFeedCacheMaxAge() + .adding(seconds: -1) + + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + + sut.load { _ in } + store.completeRetrieval( + with: uniqueImageFeed().local, + timestamp: expiredTimestamp + ) + + XCTAssertEqual(store.receivedMessages, [.retrieve]) + } + + func test_load_doesNotDeliverResultAfterSUTInstanceHasBeenDeallocated() { + let store = FeedStoreSpy() + var sut: LocalFeedLoader? = LocalFeedLoader( + store: store, + currentDate: Date.init + ) + + var receivedResults = [LocalFeedLoader.LoadResult]() + sut?.load { receivedResults.append($0) } + + sut = nil + store.completeRetrievalWithEmptyCache() + XCTAssertTrue(receivedResults.isEmpty) + } + + // 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 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) + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift new file mode 100644 index 0000000..258a5e5 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/ValidateFeedCacheUseCaseTests.swift @@ -0,0 +1,98 @@ +// +// 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, []) + } + + func test_validateCache_deletesCacheOnRetrievalError() { + let (sut, store) = makeSUT() + sut.validateCache() + store.completeRetrieval(with: anyNSError()) + + XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) + } + + func test_validateCache_doesNotDeleteCacheOnEmptyCache() { + let (sut, store) = makeSUT() + sut.validateCache() + store.completeRetrievalWithEmptyCache() + XCTAssertEqual(store.receivedMessages, [.retrieve]) + } + + func test_validateCache_doesNotDeleteNonExpiredCache() { + let fixedCurrentDate = Date() + let nonExpiredTimestamp = fixedCurrentDate + .minusFeedCacheMaxAge() + .adding(seconds: 1) + + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + sut.validateCache() + store.completeRetrieval( + with: uniqueImageFeed().local, + timestamp: nonExpiredTimestamp + ) + XCTAssertEqual(store.receivedMessages, [.retrieve]) + } + + func test_validateCache_deletesCacheOnExpiration() { + let fixedCurrentDate = Date() + let expirationTimestamp = fixedCurrentDate.minusFeedCacheMaxAge() + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + + sut.validateCache() + store.completeRetrieval(with: uniqueImageFeed().local, timestamp: expirationTimestamp) + XCTAssertEqual(store.receivedMessages, [.retrieve, .deleteCachedFeed]) + } + + func test_validateCache_deletesExpiredCache() { + let fixedCurrentDate = Date() + let expiredTimestamp = fixedCurrentDate + .minusFeedCacheMaxAge() + .adding(seconds: -1) + + let (sut, store) = makeSUT(currentDate: { fixedCurrentDate }) + + sut.validateCache() + store.completeRetrieval( + with: uniqueImageFeed().local, + timestamp: expiredTimestamp + ) + + 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, + 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) + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift new file mode 100644 index 0000000..3f59ed4 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/XCTestCase+FeedStoreSpecs.swift @@ -0,0 +1,220 @@ +// +// 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) { result in + switch result { + case let .failure(error): + insertionError = error + default: break + } + 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 { result in + exp.fulfill() + switch result { + case let .failure(error): + receivedError = error + default: break + } + } + + wait(for: [exp], timeout: 1.0) + return receivedError + } + + func expect( + _ sut: FeedStore, + toRetrieve expectedResult: FeedStore.RetrievalResult, + file: StaticString = #file, + line: UInt = #line + ) { + let exp = expectation(description: "Wait for cache retrieval") + + sut.retrieve { retrievedResult in + exp.fulfill() + switch (expectedResult, retrievedResult) { + case (.success(.none), .success(.none)), (.failure, .failure): + break + 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) + } + } + wait(for: [exp], timeout: 1.0) + } + + func expect( + _ sut: FeedStore, + toRetrieveTwice expectedResult: FeedStore.RetrievalResult, + file: StaticString = #file, + line: UInt = #line + ) { + 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, + line: UInt = #line + ) { + expect(sut, toRetrieve: .success(.none)) + } + + func assertThatRetrieveHasNoSideEffectsOnEmptyCache( + on sut: FeedStore, + file: StaticString = #file, + line: UInt = #line + ) { + expect(sut, toRetrieveTwice: .success(.none)) + } + + 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: .success(CacheFeed(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: .success(CacheFeed(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, + 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: .success(CacheFeed (feed: latestFeed, timestamp: latesTimestamp))) + } +} + +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 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, + line: UInt = #line + ) { + let deletionError = deleteCache(from: sut) + + XCTAssertNil(deletionError, "Expected empty cache deletion to succeed") + expect(sut, toRetrieve: .success(.none)) + } + + 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: .success(.none)) + } + + 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") + } +} 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")! +} 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