From 72418d6b346f1f464936d284c8d17f7a223550ed Mon Sep 17 00:00:00 2001 From: Allan Lang Date: Fri, 17 Jun 2022 12:53:18 +0100 Subject: [PATCH] Resolver based services abstraction for data access (#61) * Site settings moved to Site Service with injection * Add and adopt services for other entities * Buffer for Sites/Species/Supervisors; use services for upload session * Resolver for all db / api refs; retire EntitiesViewModel * Version bump * Integration tests and necessary config wrap for CI * Move other entity services to use Session Factory * Fix flaky test --- .github/workflows/build-release.yaml | 3 + .pouch.yml | 3 + Tree Tracker.xcodeproj/project.pbxproj | 72 ++++++-- .../xcschemes/Unit Tests.xcscheme | 8 +- Tree Tracker/AppDelegate+Injection.swift | 60 +++++++ Tree Tracker/Errors/DataAccessError.swift | 5 + Tree Tracker/Info.plist | 6 +- .../SettingsNavigationController.swift | 9 +- .../Details/AddLocalTreeViewModel.swift | 12 +- .../Details/EditLocalTreeViewModel.swift | 12 +- .../Screens/Entities/EntitiesViewModel.swift | 158 ----------------- .../Screens/Settings/AddSiteController.swift | 19 +- .../Screens/Settings/SettingsController.swift | 12 +- .../Screens/Settings/SitesController.swift | 30 ++-- .../Screens/Settings/SpeciesController.swift | 23 +-- .../Settings/SupervisorsController.swift | 20 +-- .../UploadHistoryViewModel.swift | 9 +- .../UploadSessionViewModel.swift | 52 +++++- .../Screens/Upload/UploadViewModel.swift | 10 +- .../AirtableAuthenticationAdapter.swift | 18 ++ .../Airtable/AirtableSessionFactory.swift | 68 +++++++ .../Airtable/AirtableSiteService.swift | 87 +++++++++ .../Airtable/AirtableSpeciesService.swift | 69 ++++++++ .../Airtable/AirtableSupervisorService.swift | 67 +++++++ Tree Tracker/Services/MockApi.swift | 4 +- Tree Tracker/Services/SiteService.swift | 10 ++ Tree Tracker/Services/SpeciesService.swift | 10 ++ Tree Tracker/Services/SupervisorService.swift | 10 ++ Tree Tracker/Utilities/Environment.swift | 5 - Tree Tracker/Utilities/URLImageLoader.swift | 7 +- Unit Tests/AirtableSiteServiceTests.swift | 166 ++++++++++++++++++ Unit Tests/Info.plist | 2 +- 32 files changed, 765 insertions(+), 281 deletions(-) create mode 100644 Tree Tracker/AppDelegate+Injection.swift create mode 100644 Tree Tracker/Errors/DataAccessError.swift delete mode 100644 Tree Tracker/Screens/Entities/EntitiesViewModel.swift create mode 100644 Tree Tracker/Services/Airtable/AirtableAuthenticationAdapter.swift create mode 100644 Tree Tracker/Services/Airtable/AirtableSessionFactory.swift create mode 100644 Tree Tracker/Services/Airtable/AirtableSiteService.swift create mode 100644 Tree Tracker/Services/Airtable/AirtableSpeciesService.swift create mode 100644 Tree Tracker/Services/Airtable/AirtableSupervisorService.swift create mode 100644 Tree Tracker/Services/SiteService.swift create mode 100644 Tree Tracker/Services/SpeciesService.swift create mode 100644 Tree Tracker/Services/SupervisorService.swift create mode 100644 Unit Tests/AirtableSiteServiceTests.swift diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml index 99396f8..1f78d9b 100644 --- a/.github/workflows/build-release.yaml +++ b/.github/workflows/build-release.yaml @@ -27,6 +27,9 @@ jobs: AIRTABLE_SITES_TABLE_NAME: ${{ secrets.AIRTABLE_SITES_TABLE_NAME }} CLOUDINARY_CLOUD_NAME: ${{ secrets.CLOUDINARY_CLOUD_NAME }} CLOUDINARY_UPLOAD_PRESET_NAME: ${{ secrets.CLOUDINARY_UPLOAD_PRESET_NAME }} + TEST_AIRTABLE_API_KEY: ${{ secrets.TEST_AIRTABLE_API_KEY }} + TEST_AIRTABLE_BASE_ID: ${{ secrets.TEST_AIRTABLE_BASE_ID }} + TEST_AIRTABLE_TABLE_NAME_PREFIX: ${{ secrets.TEST_AIRTABLE_TABLE_NAME_PREFIX }} run: pouch - name: Set build number run: agvtool new-version $GITHUB_RUN_NUMBER diff --git a/.pouch.yml b/.pouch.yml index 9c7e64e..79514fb 100644 --- a/.pouch.yml +++ b/.pouch.yml @@ -7,5 +7,8 @@ secrets: - AIRTABLE_SITES_TABLE_NAME - CLOUDINARY_CLOUD_NAME - CLOUDINARY_UPLOAD_PRESET_NAME +- TEST_AIRTABLE_API_KEY +- TEST_AIRTABLE_BASE_ID +- TEST_AIRTABLE_TABLE_NAME_PREFIX outputs: - ./Tree Tracker/Secrets.swift \ No newline at end of file diff --git a/Tree Tracker.xcodeproj/project.pbxproj b/Tree Tracker.xcodeproj/project.pbxproj index ecec40e..3caa4a5 100644 --- a/Tree Tracker.xcodeproj/project.pbxproj +++ b/Tree Tracker.xcodeproj/project.pbxproj @@ -101,7 +101,6 @@ 85CC02E6261B6DD90016E618 /* TableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85CC02E5261B6DD90016E618 /* TableViewController.swift */; }; 85CC02E9261B6E820016E618 /* TableListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85CC02E8261B6E820016E618 /* TableListItem.swift */; }; 85CC02EC261B6EA90016E618 /* TextTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85CC02EB261B6EA90016E618 /* TextTableViewCell.swift */; }; - 85CC02F1261B76D10016E618 /* EntitiesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85CC02F0261B76D10016E618 /* EntitiesViewModel.swift */; }; 85CC0304261CC8FE0016E618 /* LocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85CC0303261CC8FE0016E618 /* LocationManager.swift */; }; 85DC213C25E0FC8F003F0721 /* ImageCacheInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DC213B25E0FC8F003F0721 /* ImageCacheInfo.swift */; }; 85DC214025E0FC9F003F0721 /* ImageCaching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DC213F25E0FC9F003F0721 /* ImageCaching.swift */; }; @@ -110,12 +109,23 @@ 85E0E05E25B33F8C009D8FC0 /* TappableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E0E05D25B33F8C009D8FC0 /* TappableButton.swift */; }; 85E0E06225B35744009D8FC0 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E0E06125B35744009D8FC0 /* UIView.swift */; }; 9D5CDBD727BBC080007D4F0A /* ExportOptions.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9D5CDBD627BBC080007D4F0A /* ExportOptions.plist */; }; + 9D5D5E28284B630D00F3AD3E /* SpeciesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5D5E27284B630D00F3AD3E /* SpeciesService.swift */; }; + 9D5D5E2A284B635900F3AD3E /* AirtableSpeciesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5D5E29284B635900F3AD3E /* AirtableSpeciesService.swift */; }; + 9D5D5E2C284B66BB00F3AD3E /* SupervisorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5D5E2B284B66BB00F3AD3E /* SupervisorService.swift */; }; + 9D5D5E2E284B670400F3AD3E /* AirtableSupervisorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5D5E2D284B670400F3AD3E /* AirtableSupervisorService.swift */; }; + 9D79A5A7283AE03100F0F96C /* SiteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D79A5A6283AE03100F0F96C /* SiteService.swift */; }; + 9D79A5AA283AE27500F0F96C /* DataAccessError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D79A5A9283AE27500F0F96C /* DataAccessError.swift */; }; + 9D79A5AC283AE32C00F0F96C /* AirtableSiteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D79A5AB283AE32C00F0F96C /* AirtableSiteService.swift */; }; + 9DA1FC34283452CC00AEC584 /* AppDelegate+Injection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DA1FC33283452CC00AEC584 /* AppDelegate+Injection.swift */; }; 9DB29B4E281DE04F00AAC73D /* SettingsNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DB29B4D281DE04F00AAC73D /* SettingsNavigationController.swift */; }; 9DB29B562821C28400AAC73D /* SettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DB29B552821C28300AAC73D /* SettingsController.swift */; }; 9DB29B582821D50000AAC73D /* SimpleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DB29B572821D50000AAC73D /* SimpleTableViewCell.swift */; }; 9DB29B5A282876B700AAC73D /* SitesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DB29B59282876B700AAC73D /* SitesController.swift */; }; 9DB29B5E282C0AEB00AAC73D /* AddSiteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DB29B5D282C0AEB00AAC73D /* AddSiteController.swift */; }; 9DCC548C28073F0A00CF67AA /* Resolver in Frameworks */ = {isa = PBXBuildFile; productRef = 9DCC548B28073F0A00CF67AA /* Resolver */; }; + 9DFB9CC1284E9BF500298526 /* AirtableSiteServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DFB9CC0284E9BF500298526 /* AirtableSiteServiceTests.swift */; }; + 9DFB9CC52851345800298526 /* AirtableSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DFB9CC42851345800298526 /* AirtableSessionFactory.swift */; }; + 9DFB9CC7285134D200298526 /* AirtableAuthenticationAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DFB9CC6285134D200298526 /* AirtableAuthenticationAdapter.swift */; }; 9DFF6277282E63100008AEEF /* SupervisorsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DFF6276282E63100008AEEF /* SupervisorsController.swift */; }; 9DFF6279282E64780008AEEF /* SpeciesController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DFF6278282E64780008AEEF /* SpeciesController.swift */; }; /* End PBXBuildFile section */ @@ -228,7 +238,6 @@ 85CC02E5261B6DD90016E618 /* TableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewController.swift; sourceTree = ""; }; 85CC02E8261B6E820016E618 /* TableListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableListItem.swift; sourceTree = ""; }; 85CC02EB261B6EA90016E618 /* TextTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextTableViewCell.swift; sourceTree = ""; }; - 85CC02F0261B76D10016E618 /* EntitiesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitiesViewModel.swift; sourceTree = ""; }; 85CC0303261CC8FE0016E618 /* LocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManager.swift; sourceTree = ""; }; 85DC213B25E0FC8F003F0721 /* ImageCacheInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCacheInfo.swift; sourceTree = ""; }; 85DC213F25E0FC9F003F0721 /* ImageCaching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCaching.swift; sourceTree = ""; }; @@ -237,11 +246,22 @@ 85E0E05D25B33F8C009D8FC0 /* TappableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TappableButton.swift; sourceTree = ""; }; 85E0E06125B35744009D8FC0 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 9D5CDBD627BBC080007D4F0A /* ExportOptions.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = ExportOptions.plist; sourceTree = ""; }; + 9D5D5E27284B630D00F3AD3E /* SpeciesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeciesService.swift; sourceTree = ""; }; + 9D5D5E29284B635900F3AD3E /* AirtableSpeciesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSpeciesService.swift; sourceTree = ""; }; + 9D5D5E2B284B66BB00F3AD3E /* SupervisorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupervisorService.swift; sourceTree = ""; }; + 9D5D5E2D284B670400F3AD3E /* AirtableSupervisorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSupervisorService.swift; sourceTree = ""; }; + 9D79A5A6283AE03100F0F96C /* SiteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteService.swift; sourceTree = ""; }; + 9D79A5A9283AE27500F0F96C /* DataAccessError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataAccessError.swift; sourceTree = ""; }; + 9D79A5AB283AE32C00F0F96C /* AirtableSiteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSiteService.swift; sourceTree = ""; }; + 9DA1FC33283452CC00AEC584 /* AppDelegate+Injection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Injection.swift"; sourceTree = ""; }; 9DB29B4D281DE04F00AAC73D /* SettingsNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNavigationController.swift; sourceTree = ""; }; 9DB29B552821C28300AAC73D /* SettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsController.swift; sourceTree = ""; }; 9DB29B572821D50000AAC73D /* SimpleTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleTableViewCell.swift; sourceTree = ""; }; 9DB29B59282876B700AAC73D /* SitesController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitesController.swift; sourceTree = ""; }; 9DB29B5D282C0AEB00AAC73D /* AddSiteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddSiteController.swift; sourceTree = ""; }; + 9DFB9CC0284E9BF500298526 /* AirtableSiteServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSiteServiceTests.swift; sourceTree = ""; }; + 9DFB9CC42851345800298526 /* AirtableSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableSessionFactory.swift; sourceTree = ""; }; + 9DFB9CC6285134D200298526 /* AirtableAuthenticationAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirtableAuthenticationAdapter.swift; sourceTree = ""; }; 9DFF6276282E63100008AEEF /* SupervisorsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupervisorsController.swift; sourceTree = ""; }; 9DFF6278282E64780008AEEF /* SpeciesController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeciesController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -274,6 +294,7 @@ 851DAC2B262F2FA70087E1D4 /* Info.plist */, 851DAC35262F2FE30087E1D4 /* RecentSpeciesManagerTests.swift */, 851DAC39262F35B80087E1D4 /* TestHelpers.swift */, + 9DFB9CC0284E9BF500298526 /* AirtableSiteServiceTests.swift */, ); path = "Unit Tests"; sourceTree = ""; @@ -312,6 +333,7 @@ 853ABD542596144900144B0D /* Tree Tracker */ = { isa = PBXGroup; children = ( + 9D79A5A8283AE25300F0F96C /* Errors */, 857BADAA25B1FA9D005D7D35 /* Navigation */, 85792A8925B1D56D00BFDA96 /* Screens */, 85E0E05C25B33F73009D8FC0 /* UI Components */, @@ -328,6 +350,7 @@ 853ABD602596144A00144B0D /* LaunchScreen.storyboard */, 853ABD632596144A00144B0D /* Info.plist */, 9D5CDBD627BBC080007D4F0A /* ExportOptions.plist */, + 9DA1FC33283452CC00AEC584 /* AppDelegate+Injection.swift */, ); path = "Tree Tracker"; sourceTree = ""; @@ -393,7 +416,6 @@ isa = PBXGroup; children = ( 9DCC548D2807815E00CF67AA /* Settings */, - 85CC02EF261B76C80016E618 /* Entities */, 853415ED25CEEE28006DDAC4 /* Upload Session */, 8547FC95261E191A0062B82D /* Upload History */, 85B83A0F25B87E310008E167 /* Upload */, @@ -489,11 +511,15 @@ 85A0EF9025A231F2003CE744 /* Services */ = { isa = PBXGroup; children = ( + 9D5D5E2F284B698600F3AD3E /* Airtable */, 6CC985AA27F393630027C795 /* RetryingRequestInterceptor.swift */, 853ABD7F25961EEA00144B0D /* Api.swift */, 85792A7C25B0A3E100BFDA96 /* Database.swift */, 857123C7263997FD008EE027 /* AlamofireApi.swift */, 857123CB26399B49008EE027 /* MockApi.swift */, + 9D79A5A6283AE03100F0F96C /* SiteService.swift */, + 9D5D5E27284B630D00F3AD3E /* SpeciesService.swift */, + 9D5D5E2B284B66BB00F3AD3E /* SupervisorService.swift */, ); path = Services; sourceTree = ""; @@ -516,14 +542,6 @@ path = Upload; sourceTree = ""; }; - 85CC02EF261B76C80016E618 /* Entities */ = { - isa = PBXGroup; - children = ( - 85CC02F0261B76D10016E618 /* EntitiesViewModel.swift */, - ); - path = Entities; - sourceTree = ""; - }; 85DC213A25E0FC7A003F0721 /* Image Cache */ = { isa = PBXGroup; children = ( @@ -559,6 +577,26 @@ path = "UI Components"; sourceTree = ""; }; + 9D5D5E2F284B698600F3AD3E /* Airtable */ = { + isa = PBXGroup; + children = ( + 9D79A5AB283AE32C00F0F96C /* AirtableSiteService.swift */, + 9D5D5E29284B635900F3AD3E /* AirtableSpeciesService.swift */, + 9D5D5E2D284B670400F3AD3E /* AirtableSupervisorService.swift */, + 9DFB9CC42851345800298526 /* AirtableSessionFactory.swift */, + 9DFB9CC6285134D200298526 /* AirtableAuthenticationAdapter.swift */, + ); + path = Airtable; + sourceTree = ""; + }; + 9D79A5A8283AE25300F0F96C /* Errors */ = { + isa = PBXGroup; + children = ( + 9D79A5A9283AE27500F0F96C /* DataAccessError.swift */, + ); + path = Errors; + sourceTree = ""; + }; 9DCC548D2807815E00CF67AA /* Settings */ = { isa = PBXGroup; children = ( @@ -690,6 +728,7 @@ files = ( 851DAC36262F2FE30087E1D4 /* RecentSpeciesManagerTests.swift in Sources */, 851DAC3A262F35B80087E1D4 /* TestHelpers.swift in Sources */, + 9DFB9CC1284E9BF500298526 /* AirtableSiteServiceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -721,17 +760,20 @@ 851DAC1D262B4B0B0087E1D4 /* Date.swift in Sources */, 9DB29B4E281DE04F00AAC73D /* SettingsNavigationController.swift in Sources */, 85B839EC25B8661E0008E167 /* Collection.swift in Sources */, + 9D79A5A7283AE03100F0F96C /* SiteService.swift in Sources */, 85B83A1525B87E780008E167 /* UploadViewModel.swift in Sources */, 85792A7625B0A35A00BFDA96 /* UIImage.swift in Sources */, 85792A7A25B0A36B00BFDA96 /* URL.swift in Sources */, 9DB29B5E282C0AEB00AAC73D /* AddSiteController.swift in Sources */, 85B839DD25B74FE00008E167 /* KeyboardAccessory.swift in Sources */, + 9D5D5E28284B630D00F3AD3E /* SpeciesService.swift in Sources */, 85B83A1825B881EC0008E167 /* LocalTree.swift in Sources */, 859F62E925C48FA2005E61F7 /* DelayedPublished.swift in Sources */, 85B839F325B866590008E167 /* RoundedTappableButton.swift in Sources */, 85C781A125CC744E0034292D /* ScreenLockManager.swift in Sources */, 85B839AD25B47BD30008E167 /* Reusable.swift in Sources */, 85B839A925B35B540008E167 /* TableViewDataSource.swift in Sources */, + 9D79A5AA283AE27500F0F96C /* DataAccessError.swift in Sources */, 85B83A3825B9D9C40008E167 /* Data.swift in Sources */, 85B83A2D25B9C5DB0008E167 /* DateFormatter.swift in Sources */, 859F62E425C22D6C005E61F7 /* SelectionsKeyboardView.swift in Sources */, @@ -740,7 +782,6 @@ 859F62D625C22140005E61F7 /* Species.swift in Sources */, 85B83A2325B9C1BC0008E167 /* NavigationViewController.swift in Sources */, 85DC214625E0FCBF003F0721 /* GRDBImageCache.swift in Sources */, - 85CC02F1261B76D10016E618 /* EntitiesViewModel.swift in Sources */, 859F62FC25C70F29005E61F7 /* CollectionViewController.swift in Sources */, 85792A9225B1D5A500BFDA96 /* ButtonModel.swift in Sources */, 85B839CE25B5F8F30008E167 /* UIAlertController.swift in Sources */, @@ -751,9 +792,12 @@ 85CC0304261CC8FE0016E618 /* LocationManager.swift in Sources */, 859F62D125C1C62D005E61F7 /* AirtableSupervisor.swift in Sources */, 9DB29B562821C28400AAC73D /* SettingsController.swift in Sources */, + 9D5D5E2E284B670400F3AD3E /* AirtableSupervisorService.swift in Sources */, 853ABD562596144900144B0D /* AppDelegate.swift in Sources */, 857BADA825B1FA93005D7D35 /* TreeDetailsViewController.swift in Sources */, + 9D5D5E2C284B66BB00F3AD3E /* SupervisorService.swift in Sources */, 85B83A1C25B8AC650008E167 /* EditLocalTreeViewModel.swift in Sources */, + 9DFB9CC7285134D200298526 /* AirtableAuthenticationAdapter.swift in Sources */, 85B83A2A25B9C5CB0008E167 /* JSONDecoder.swift in Sources */, 85A0EF8925A22FEE003CE744 /* AirtableTree.swift in Sources */, 85B839E025B74FF50008E167 /* TextField.swift in Sources */, @@ -776,16 +820,19 @@ 858A0F2725D8156100E12C2B /* TreeDetailsFlowViewController.swift in Sources */, 859F62DC25C2218B005E61F7 /* Supervisor.swift in Sources */, 85B839FB25B86F1A0008E167 /* ImageLoader.swift in Sources */, + 9D5D5E2A284B635900F3AD3E /* AirtableSpeciesService.swift in Sources */, 85B839EF25B866370008E167 /* GridCollectionViewLayout.swift in Sources */, 85B839E725B862B80008E167 /* CollectionViewDataSource.swift in Sources */, 8534160025CF09CE006DDAC4 /* PHPhotoLibrary.swift in Sources */, 853415FD25CF0957006DDAC4 /* UIEdgeInsets.swift in Sources */, 85B83A3025B9C5E90008E167 /* JSONEncoder.swift in Sources */, + 9DA1FC34283452CC00AEC584 /* AppDelegate+Injection.swift in Sources */, 853415F025CEEE39006DDAC4 /* UploadSessionViewController.swift in Sources */, 85792A8625B1D53700BFDA96 /* Environment.swift in Sources */, 85DC214025E0FC9F003F0721 /* ImageCaching.swift in Sources */, 8563C263260BBC7A00752793 /* Secrets.swift in Sources */, 85792A8F25B1D59F00BFDA96 /* SyncProgress.swift in Sources */, + 9DFB9CC52851345800298526 /* AirtableSessionFactory.swift in Sources */, 85B83A0225B8735F0008E167 /* AnyImageLoader.swift in Sources */, 85CC02E9261B6E820016E618 /* TableListItem.swift in Sources */, 85CC02EC261B6EA90016E618 /* TextTableViewCell.swift in Sources */, @@ -794,6 +841,7 @@ 859F62C325C1C44C005E61F7 /* AirtableImage.swift in Sources */, 857BADAC25B1FAAA005D7D35 /* UploadListFlowViewController.swift in Sources */, 859F62EF25C48FD8005E61F7 /* AlertModel.swift in Sources */, + 9D79A5AC283AE32C00F0F96C /* AirtableSiteService.swift in Sources */, 8547FC97261E19320062B82D /* UploadHistoryViewModel.swift in Sources */, 857123D02639C3AF008EE027 /* ClosureCancellable.swift in Sources */, 85B839BB25B4814F0008E167 /* ListSection.swift in Sources */, diff --git a/Tree Tracker.xcodeproj/xcshareddata/xcschemes/Unit Tests.xcscheme b/Tree Tracker.xcodeproj/xcshareddata/xcschemes/Unit Tests.xcscheme index 51c2349..d02e336 100644 --- a/Tree Tracker.xcodeproj/xcshareddata/xcschemes/Unit Tests.xcscheme +++ b/Tree Tracker.xcodeproj/xcshareddata/xcschemes/Unit Tests.xcscheme @@ -10,7 +10,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "NO"> + + + + diff --git a/Tree Tracker/AppDelegate+Injection.swift b/Tree Tracker/AppDelegate+Injection.swift new file mode 100644 index 0000000..2408d5a --- /dev/null +++ b/Tree Tracker/AppDelegate+Injection.swift @@ -0,0 +1,60 @@ +import Resolver +import Photos +import UIKit + +extension Resolver: ResolverRegistering { + + static let mock = Resolver(child: main) + static let integrationTest = Resolver(child: main) + + public static func registerAllServices() { + // register all components as singletons for lifetime of application + // defaultScope = .application + + // MARK: Base services + register { Logger(output: .print) }.implements(Logging.self) + register { Database(logger: resolve()) } + register { AlamofireApi(logger: resolve()) }.implements(Api.self) + register { Defaults() } + register { GRDBImageCache(logger: resolve()) } + register { UIScreenLockManager() } + register { PHCachingImageManager() } + register { RecentSpeciesManager(defaults: resolve(), strategy: .todayUsedSpecies) } + + // MARK: Services + register { AirtableSessionFactory(airtableBaseId: Constants.Airtable.baseId, + airtableApiKey: Constants.Airtable.apiKey, + httpRequestTimeoutSeconds: Constants.Http.requestTimeoutSeconds, + httpWaitsForConnectivity: true, + httpRetryDelaySeconds: Constants.Http.requestRetryDelaySeconds, + httpRetryLimit: Constants.Http.requestRetryLimit) } + register { AirtableSiteService() as SiteService } + register { AirtableSpeciesService() as SpeciesService } + register { AirtableSupervisorService() as SupervisorService } + + // MARK: Controllers + register { SitesController() } + register { SpeciesController() } + register { SupervisorsController() } + register { SettingsController(style: UITableView.Style.grouped) } + + // MARK: test component registrations + mock.register { MockApi() as Api } + + integrationTest.register { AirtableSessionFactory(airtableBaseId: Secrets.testAirtableBaseId, + airtableApiKey: Secrets.testAirtableApiKey, + airtableTablePrefix: Secrets.testAirtableTableNamePrefix, + httpRequestTimeoutSeconds: Constants.Http.requestTimeoutSeconds, + httpWaitsForConnectivity: true, + httpRetryDelaySeconds: Constants.Http.requestRetryDelaySeconds, + httpRetryLimit: Constants.Http.requestRetryLimit) } + + if CommandLine.arguments.contains("--mock-server") { + Resolver.root = Resolver.mock + } + + if CommandLine.arguments.contains("--integration-test") { + Resolver.root = Resolver.integrationTest + } + } +} diff --git a/Tree Tracker/Errors/DataAccessError.swift b/Tree Tracker/Errors/DataAccessError.swift new file mode 100644 index 0000000..5d2ec8f --- /dev/null +++ b/Tree Tracker/Errors/DataAccessError.swift @@ -0,0 +1,5 @@ +import Foundation + +enum DataAccessError: Error { + case remoteError(errorCode: Int, errorMessage: String) +} diff --git a/Tree Tracker/Info.plist b/Tree Tracker/Info.plist index a399fb4..7b467df 100644 --- a/Tree Tracker/Info.plist +++ b/Tree Tracker/Info.plist @@ -2,8 +2,6 @@ - ITSAppUsesNonExemptEncryption - CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -19,9 +17,11 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.8.0 + 0.8.1 CFBundleVersion $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS NSCameraUsageDescription diff --git a/Tree Tracker/Navigation/SettingsNavigationController.swift b/Tree Tracker/Navigation/SettingsNavigationController.swift index 90aad86..1b3ab5f 100644 --- a/Tree Tracker/Navigation/SettingsNavigationController.swift +++ b/Tree Tracker/Navigation/SettingsNavigationController.swift @@ -1,18 +1,17 @@ import Foundation import UIKit +import Resolver /* Navigation controller for Settings - acts as a container for child view controllers */ class SettingsNavigationController: UINavigationController { + @Injected var settingsContoller: SettingsController + init() { super.init(nibName: nil, bundle: nil) - - let top = SettingsController(style: UITableView.Style.grouped) - - self.viewControllers = [top] - + self.viewControllers = [settingsContoller] self.title = "Settings" } diff --git a/Tree Tracker/Screens/Details/AddLocalTreeViewModel.swift b/Tree Tracker/Screens/Details/AddLocalTreeViewModel.swift index 213e94b..eb398fd 100644 --- a/Tree Tracker/Screens/Details/AddLocalTreeViewModel.swift +++ b/Tree Tracker/Screens/Details/AddLocalTreeViewModel.swift @@ -1,5 +1,6 @@ import Combine import Photos +import Resolver protocol TreeDetailsNavigating: AnyObject { func detailsFilledSuccessfully() @@ -33,9 +34,9 @@ final class AddLocalTreeViewModel: TreeDetailsViewModel { var saveButtonPublisher: Published.Publisher { $saveButton } var topRightNavigationButtonPublisher: Published.Publisher { $topRightNavigationButton } - private let api: Api - private let database: Database - private let defaults: Defaults + @Injected private var database: Database + @Injected private var defaults: Defaults + private let recentSpeciesManager: RecentSpeciesManaging private let initialAssetCount: Int private var currentAsset: Int @@ -47,10 +48,7 @@ final class AddLocalTreeViewModel: TreeDetailsViewModel { private var supervisors: [Supervisor] = [] private weak var navigation: TreeDetailsNavigating? - init(api: Api = CurrentEnvironment.api, database: Database = CurrentEnvironment.database, defaults: Defaults = CurrentEnvironment.defaults, recentSpeciesManager: RecentSpeciesManaging = CurrentEnvironment.recentSpeciesManager, assets: [PHAsset], staticSupervisor: Supervisor?, staticSite: Site?, navigation: TreeDetailsNavigating) { - self.api = api - self.database = database - self.defaults = defaults + init(recentSpeciesManager: RecentSpeciesManaging = CurrentEnvironment.recentSpeciesManager, assets: [PHAsset], staticSupervisor: Supervisor?, staticSite: Site?, navigation: TreeDetailsNavigating) { self.recentSpeciesManager = recentSpeciesManager self.navigation = navigation self.assets = assets diff --git a/Tree Tracker/Screens/Details/EditLocalTreeViewModel.swift b/Tree Tracker/Screens/Details/EditLocalTreeViewModel.swift index 1ccdd38..4692fd0 100644 --- a/Tree Tracker/Screens/Details/EditLocalTreeViewModel.swift +++ b/Tree Tracker/Screens/Details/EditLocalTreeViewModel.swift @@ -1,5 +1,6 @@ import Combine import Photos +import Resolver final class EditLocalTreeViewModel: TreeDetailsViewModel { @DelayedPublished var alert: AlertModel @@ -18,19 +19,16 @@ final class EditLocalTreeViewModel: TreeDetailsViewModel { var saveButtonPublisher: Published.Publisher { $saveButton } var topRightNavigationButtonPublisher: Published.Publisher { $topRightNavigationButton } - private let api: Api - private let database: Database - private let defaults: Defaults + @Injected private var database: Database + @Injected private var defaults: Defaults + private var tree: LocalTree private var sites: [Site] = [] private var species: [Species] = [] private var supervisors: [Supervisor] = [] private weak var navigation: TreeDetailsNavigating? - init(api: Api = CurrentEnvironment.api, database: Database = CurrentEnvironment.database, defaults: Defaults = CurrentEnvironment.defaults, tree: LocalTree, navigation: TreeDetailsNavigating) { - self.api = api - self.database = database - self.defaults = defaults + init(tree: LocalTree, navigation: TreeDetailsNavigating) { self.navigation = navigation self.tree = tree self.fields = [] diff --git a/Tree Tracker/Screens/Entities/EntitiesViewModel.swift b/Tree Tracker/Screens/Entities/EntitiesViewModel.swift deleted file mode 100644 index 6499c7b..0000000 --- a/Tree Tracker/Screens/Entities/EntitiesViewModel.swift +++ /dev/null @@ -1,158 +0,0 @@ -import Foundation - -private extension LogCategory { - static var entities = LogCategory(name: "Entities") -} - -final class EntitiesViewModel: TableViewModel { - @DelayedPublished var alert: AlertModel - @Published var title: String - @Published var data: [ListSection] - @Published var rightNavigationButtons: [NavigationBarButtonModel] - @Published var actionButton: ButtonModel? - - var alertPublisher: DelayedPublished.Publisher { $alert } - var titlePublisher: Published.Publisher { $title } - var actionButtonPublisher: Published.Publisher { $actionButton } - var rightNavigationButtonsPublisher: Published<[NavigationBarButtonModel]>.Publisher { $rightNavigationButtons } - var dataPublisher: Published<[ListSection]>.Publisher { $data } - - private let api: Api - private let database: Database - private let logger: Logging - private var sites: [Site] = [] - private var species: [Species] = [] - private var supervisors: [Supervisor] = [] - - init(api: Api = CurrentEnvironment.api, database: Database = CurrentEnvironment.database, logger: Logging = CurrentEnvironment.logger) { - self.title = "Entities" - self.api = api - self.database = database - self.logger = logger - self.data = [] - self.rightNavigationButtons = [] - - self.rightNavigationButtons = [ - .init( - title: .system(.refresh), - action: { [weak self] in self?.sync() }, - isEnabled: true - ) - ] - - preheatEntities() - } - - func onAppear() { - refreshData(syncOnEmptyData: true) - } - - private func preheatEntities() { - fetchDatabaseContent { [weak self] in - if self?.sites.isEmpty == true || self?.supervisors.isEmpty == true || self?.species.isEmpty == true { - self?.sync() - } - } - } - - private func refreshData(syncOnEmptyData: Bool = false) { - fetchDatabaseContent { [weak self] in - self?.presentContentFromDatabase() - if syncOnEmptyData, self?.sites.isEmpty == true || self?.supervisors.isEmpty == true || self?.species.isEmpty == true { - self?.sync() - } - } - } - - func sync() { - fetchAndReplaceAllSitesFromRemote() - fetchAndReplaceAllSpeciesFromRemote() - fetchAndReplaceAllSupervisorsFromRemote() - } - - private func fetchDatabaseContent(completion: @escaping () -> Void) { - database.fetch(Site.self, Supervisor.self, Species.self) { [weak self] sites, supervisors, species in - self?.sites = sites.sorted(by: \.name, order: .ascending) - self?.supervisors = supervisors.sorted(by: \.name, order: .ascending) - self?.species = species.sorted(by: \.name, order: .ascending) - completion() - } - } - - private func fetchAndReplaceAllSpeciesFromRemote(offset: String? = nil, currentSpecies: [Species] = []) { - var newSpecies = currentSpecies - api.species(offset: offset) { [weak self] result in - switch result { - case let .success(paginatedResults): - newSpecies.append(contentsOf: paginatedResults.records.map { $0.toSpecies() }) - if let offset = paginatedResults.offset { - self?.fetchAndReplaceAllSpeciesFromRemote(offset: offset, currentSpecies: newSpecies) - } else { - self?.database.replace(newSpecies) { - self?.refreshData() - } - } - case let .failure(error): - self?.logger.log(.entities, "Error when fetching airtable records for Species: \(error)") - } - } - } - - private func fetchAndReplaceAllSupervisorsFromRemote(offset: String? = nil, currentSupervisors: [Supervisor] = []) { - var newSupervisors = currentSupervisors - api.supervisors(offset: offset) { [weak self] result in - switch result { - case let .success(paginatedResults): - newSupervisors.append(contentsOf: paginatedResults.records.map { $0.toSupervisor() }) - if let offset = paginatedResults.offset { - self?.fetchAndReplaceAllSupervisorsFromRemote(offset: offset, currentSupervisors: newSupervisors) - } else { - self?.database.replace(newSupervisors) { - self?.refreshData() - } - } - case let .failure(error): - self?.logger.log(.entities, "Error when fetching airtable records for Supervisors: \(error)") - } - } - } - - private func fetchAndReplaceAllSitesFromRemote(offset: String? = nil, currentSites: [Site] = []) { - var newSites = currentSites - api.sites(offset: offset) { [weak self] result in - switch result { - case let .success(paginatedResults): - newSites.append(contentsOf: paginatedResults.records.map { $0.toSite() }) - if let offset = paginatedResults.offset { - self?.fetchAndReplaceAllSitesFromRemote(offset: offset, currentSites: newSites) - } else { - self?.database.replace(newSites) { - self?.refreshData() - } - } - case let .failure(error): - self?.logger.log(.entities, "Error when fetching airtable records for Sites: \(error)") - } - } - } - - private func presentContentFromDatabase() { - self.data = [ - .titled("Sites", sites.map { site in - return .text(id: site.id, text: site.name, tapAction: Action(id: "site_action_\(site.id)") { [weak self] in - print("Site tapped") - }) - }), - .titled("Supervisors", supervisors.map { supervisor in - return .text(id: supervisor.id, text: supervisor.name, tapAction: Action(id: "supervisor_action_\(supervisor.id)") { [weak self] in - print("Supervisor tapped") - }) - }), - .titled("Species", species.map { species in - return .text(id: species.id, text: species.name, tapAction: Action(id: "species_action_\(species.id)") { [weak self] in - print("Species tapped") - }) - }), - ] - } -} diff --git a/Tree Tracker/Screens/Settings/AddSiteController.swift b/Tree Tracker/Screens/Settings/AddSiteController.swift index 0e3d384..322c803 100644 --- a/Tree Tracker/Screens/Settings/AddSiteController.swift +++ b/Tree Tracker/Screens/Settings/AddSiteController.swift @@ -1,19 +1,19 @@ import Foundation import UIKit +import Resolver /* Controller for sheet view used to supply and save a new site */ class AddSiteController: UIViewController, UITextFieldDelegate { - private let api = CurrentEnvironment.api - private var model: EntitiesViewModel + private var siteService: SiteService - init(entitiesViewModel: EntitiesViewModel) { - self.model = entitiesViewModel + init(siteService: SiteService) { + self.siteService = siteService super.init(nibName: nil, bundle: nil) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -77,13 +77,8 @@ class AddSiteController: UIViewController, UITextFieldDelegate { // set action button to spinner / working actionButton.set(title: .loading) - // save new site via API - api.addSite(name: textField.text!, completion: { result in - // trigger refresh of EntitiesViewModel which will also resync local database to cloud table - self.model.sync() - - // dismiss the view - self.dismiss(animated: true) + siteService.addSite(name: textField.text!, completion: { [weak self] result in + self?.dismiss(animated: true) }) } // just ignore the tap if there is no text in the text box - tap outside sheet to dismiss diff --git a/Tree Tracker/Screens/Settings/SettingsController.swift b/Tree Tracker/Screens/Settings/SettingsController.swift index cf8d6c0..7e62894 100644 --- a/Tree Tracker/Screens/Settings/SettingsController.swift +++ b/Tree Tracker/Screens/Settings/SettingsController.swift @@ -1,12 +1,16 @@ import Foundation import UIKit -import Combine +import Resolver /* Top level Settings controller */ class SettingsController: UITableViewController { + @Injected private var sitesController: SitesController + @Injected private var speciesController: SpeciesController + @Injected private var supervisorsController: SupervisorsController + private var entityTypes = ["Sites", "Supervisors", "Species"] override func viewDidLoad() { @@ -40,11 +44,11 @@ class SettingsController: UITableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch entityTypes[indexPath.item] { case "Sites": - self.navigationController?.pushViewController(SitesController(), animated: true) + self.navigationController?.pushViewController(sitesController, animated: true) case "Supervisors": - self.navigationController?.pushViewController(SupervisorsController(), animated: true) + self.navigationController?.pushViewController(supervisorsController, animated: true) case "Species": - self.navigationController?.pushViewController(SpeciesController(), animated: true) + self.navigationController?.pushViewController(speciesController, animated: true) default: break } diff --git a/Tree Tracker/Screens/Settings/SitesController.swift b/Tree Tracker/Screens/Settings/SitesController.swift index 49693fd..413b97c 100644 --- a/Tree Tracker/Screens/Settings/SitesController.swift +++ b/Tree Tracker/Screens/Settings/SitesController.swift @@ -1,21 +1,21 @@ import Foundation import UIKit import Combine +import Resolver /* Controller for sites list */ class SitesController: UITableViewController { - private let database = CurrentEnvironment.database - private var entitiesModel: EntitiesViewModel = EntitiesViewModel() + @Injected var siteService: SiteService private var sites: [Site] = [] private var cancellable: AnyCancellable! override func viewDidLoad() { super.viewDidLoad() - + self.title = "Sites" self.tableView.register(SimpleTableViewCell.self, forCellReuseIdentifier: "basicStyle") @@ -24,23 +24,18 @@ class SitesController: UITableViewController { navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addTapped)) navigationItem.rightBarButtonItems?.append(UIBarButtonItem(barButtonSystemItem: .refresh, target: self, action: #selector(refreshTapped))) - // here we are creating a Combine subscription to a @Published attribute of the entity view model which is handling data access - // the closure will be invoked on any change to the data property, which is itself refreshed via the onAppear method called - // in this controllers viewWillAppear() handler - cancellable = entitiesModel.$data.sink() { [weak self] data in - // refresh local sites array from database - self?.database.fetchAll(Site.self, completion: { [weak self] sites in - self?.sites = sites.sorted(by: \.name, order: .ascending) - // reload table view - self?.tableView.reloadData() - }) - + // Here we are creating a Combine subscription to a @Published attribute of the SiteService which is handling data access. + // The closure will be invoked on any change to the data property. + cancellable = siteService.sitesPublisher.sink() { [weak self] data in + self?.sites = data.sorted(by: \.name, order: .ascending) + // reload table view + self?.tableView.reloadData() } } // MARK: - navigation item delegates @objc func addTapped() { - let addSiteController = AddSiteController(entitiesViewModel: entitiesModel) + let addSiteController = AddSiteController(siteService: self.siteService) if let sheet = addSiteController.sheetPresentationController { sheet.detents = [ .medium() ] } @@ -48,13 +43,12 @@ class SitesController: UITableViewController { } @objc func refreshTapped() { - entitiesModel.sync() + siteService.sync() {_ in } } // MARK: - Delegate - override func viewWillAppear(_ animated: Bool) { - entitiesModel.onAppear() + } // MARK: - Datasource diff --git a/Tree Tracker/Screens/Settings/SpeciesController.swift b/Tree Tracker/Screens/Settings/SpeciesController.swift index e25f906..ef6173e 100644 --- a/Tree Tracker/Screens/Settings/SpeciesController.swift +++ b/Tree Tracker/Screens/Settings/SpeciesController.swift @@ -1,14 +1,14 @@ import Foundation import UIKit import Combine +import Resolver /* Controller for species list */ class SpeciesController: UITableViewController { - private let database = CurrentEnvironment.database - private var entitiesModel: EntitiesViewModel = EntitiesViewModel() + @Injected var speciesService: SpeciesService private var species: [Species] = [] private var cancellable: AnyCancellable! @@ -23,28 +23,21 @@ class SpeciesController: UITableViewController { // nav bar controls navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .refresh, target: self, action: #selector(refreshTapped)) - // here we are creating a Combine subscription to a @Published attribute of the entity view model which is handling data access - // the closure will be invoked on any change to the data property, which is itself refreshed via the onAppear method called - // in this controllers viewWillAppear() handler - cancellable = entitiesModel.$data.sink() { [weak self] data in - // refresh local sites array from database - self?.database.fetchAll(Species.self, completion: { [weak self] species in - self?.species = species.sorted(by: \.name, order: .ascending) - // reload table view - self?.tableView.reloadData() - }) - + cancellable = speciesService.speciesPublisher.sink() { [weak self] data in + self?.species = data.sorted(by: \.name, order: .ascending) + // reload table view + self?.tableView.reloadData() } } // MARK: - navigation item delegates @objc func refreshTapped() { - entitiesModel.sync() + speciesService.sync() {_ in} } // MARK: - Delegate override func viewWillAppear(_ animated: Bool) { - entitiesModel.onAppear() + } // MARK: - Datasource diff --git a/Tree Tracker/Screens/Settings/SupervisorsController.swift b/Tree Tracker/Screens/Settings/SupervisorsController.swift index 27110e7..ec2b0fd 100644 --- a/Tree Tracker/Screens/Settings/SupervisorsController.swift +++ b/Tree Tracker/Screens/Settings/SupervisorsController.swift @@ -1,14 +1,14 @@ import Foundation import UIKit import Combine +import Resolver /* Controller for supervisors list */ class SupervisorsController: UITableViewController { - private let database = CurrentEnvironment.database - private var entitiesModel: EntitiesViewModel = EntitiesViewModel() + @Injected var supervisorService: SupervisorService private var supervisors: [Supervisor] = [] private var cancellable: AnyCancellable! @@ -26,25 +26,21 @@ class SupervisorsController: UITableViewController { // here we are creating a Combine subscription to a @Published attribute of the entity view model which is handling data access // the closure will be invoked on any change to the data property, which is itself refreshed via the onAppear method called // in this controllers viewWillAppear() handler - cancellable = entitiesModel.$data.sink() { [weak self] data in - // refresh local sites array from database - self?.database.fetchAll(Supervisor.self, completion: { [weak self] supervisors in - self?.supervisors = supervisors.sorted(by: \.name, order: .ascending) - // reload table view - self?.tableView.reloadData() - }) - + cancellable = supervisorService.supervisorPublisher.sink() { [weak self] data in + self?.supervisors = data.sorted(by: \.name, order: .ascending) + // reload table view + self?.tableView.reloadData() } } // MARK: - navigation item delegates @objc func refreshTapped() { - entitiesModel.sync() + supervisorService.sync() {_ in} } // MARK: - Delegate override func viewWillAppear(_ animated: Bool) { - entitiesModel.onAppear() + } // MARK: - Datasource diff --git a/Tree Tracker/Screens/Upload History/UploadHistoryViewModel.swift b/Tree Tracker/Screens/Upload History/UploadHistoryViewModel.swift index 6b21721..647f337 100644 --- a/Tree Tracker/Screens/Upload History/UploadHistoryViewModel.swift +++ b/Tree Tracker/Screens/Upload History/UploadHistoryViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import Resolver private extension LogCategory { static var treeList = LogCategory(name: "TreeList") @@ -17,17 +18,15 @@ final class UploadHistoryViewModel: CollectionViewModel { var rightNavigationButtonsPublisher: Published<[NavigationBarButtonModel]>.Publisher { $rightNavigationButtons } var dataPublisher: Published<[ListSection]>.Publisher { $data } - private let api: Api - private let database: Database + @Injected private var database: Database + private let logger: Logging private var sites: [Site] = [] private var species: [Species] = [] private var supervisors: [Supervisor] = [] - init(api: Api = CurrentEnvironment.api, database: Database = CurrentEnvironment.database, logger: Logging = CurrentEnvironment.logger) { + init(logger: Logging = CurrentEnvironment.logger) { self.title = "Upload History" - self.api = api - self.database = database self.logger = logger self.data = [] self.rightNavigationButtons = [] diff --git a/Tree Tracker/Screens/Upload Session/UploadSessionViewModel.swift b/Tree Tracker/Screens/Upload Session/UploadSessionViewModel.swift index 3fb4aa4..23db8b2 100644 --- a/Tree Tracker/Screens/Upload Session/UploadSessionViewModel.swift +++ b/Tree Tracker/Screens/Upload Session/UploadSessionViewModel.swift @@ -2,6 +2,8 @@ import Foundation import class UIKit.UIImage import class CoreLocation.CLLocation import class Photos.PHAsset +import Resolver +import Combine protocol UploadSessionNavigating: AnyObject { func triggerAskForDetailsAndStoreFlow(assets: [PHAsset], site: Site?, supervisor: Supervisor?, completion: @escaping (Bool) -> Void) @@ -17,27 +19,63 @@ final class UploadSessionViewModel { @DelayedPublished var alert: AlertModel @DelayedPublished var fields: [TextFieldModel] + @Injected private var siteService: SiteService + @Injected private var supervisorService: SupervisorService + private let navigation: UploadSessionNavigating private let assetManager: AssetManaging - private let database: Database private let locationManager: LocationProviding & PermissionAsking private var sites: [Site] = [] private var supervisors: [Supervisor] = [] + + private var observables = Set() - init(navigation: UploadSessionNavigating, assetManager: AssetManaging = PHAssetManager(), database: Database = CurrentEnvironment.database, locationManager: LocationProviding & PermissionAsking = LocationManager()) { + init(navigation: UploadSessionNavigating, assetManager: AssetManaging = PHAssetManager(), locationManager: LocationProviding & PermissionAsking = LocationManager()) { self.navigation = navigation self.assetManager = assetManager - self.database = database self.locationManager = locationManager + + siteService.sitesPublisher.sink() { [weak self] data in + self?.sites = data.sorted(by: \.name, order: .ascending) + self?.presentContent() + }.store(in: &observables) + + supervisorService.supervisorPublisher.sink() { [weak self] data in + self?.supervisors = data.sorted(by: \.name, order: .ascending) + self?.presentContent() + }.store(in: &observables) } func onLoad() {} private func fetchDatabaseContent(completion: @escaping () -> Void) { - database.fetch(Site.self, Supervisor.self) { [weak self] sites, supervisors in - self?.sites = sites.sorted(by: \.name, order: .ascending) - self?.supervisors = supervisors.sorted(by: \.name, order: .ascending) - completion() + print("UploadSessionViewModel.fetchDatabaseContent") + fetchSites() { [weak self] in + self?.fetchSupervisors(completion: completion) + } + } + + private func fetchSites(completion: @escaping () -> Void) { + siteService.fetchAll() { [weak self] result in + do { + self?.sites = try result.get().sorted(by: \.name, order: .ascending) + print("\(self?.sites.count) sites loaded") + completion() + } catch { + print("Error loading sites") + } + } + } + + private func fetchSupervisors(completion: @escaping () -> Void) { + supervisorService.fetchAll() { [weak self] result in + do { + self?.supervisors = try result.get().sorted(by: \.name, order: .ascending) + print("\(self?.supervisors.count) supervisors loaded") + completion() + } catch { + print("Error loading supervisors") + } } } diff --git a/Tree Tracker/Screens/Upload/UploadViewModel.swift b/Tree Tracker/Screens/Upload/UploadViewModel.swift index 6a0e506..b792856 100644 --- a/Tree Tracker/Screens/Upload/UploadViewModel.swift +++ b/Tree Tracker/Screens/Upload/UploadViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import Resolver protocol UploadNavigating: AnyObject { func triggerAddTreesFlow(completion: @escaping (Bool) -> Void) @@ -22,8 +23,9 @@ final class UploadViewModel: CollectionViewModel { var rightNavigationButtonsPublisher: Published<[NavigationBarButtonModel]>.Publisher { $rightNavigationButtons } var dataPublisher: Published<[ListSection]>.Publisher { $data } - private var api: Api - private var database: Database + @Injected private var api: Api + @Injected private var database: Database + private var screenLockManager: ScreenLockManaging private var logger: Logging private var sites: [Site] = [] @@ -32,10 +34,8 @@ final class UploadViewModel: CollectionViewModel { private var currentUpload: Cancellable? private weak var navigation: UploadNavigating? - init(api: Api = CurrentEnvironment.api, database: Database = CurrentEnvironment.database, screenLockManager: ScreenLockManaging = CurrentEnvironment.screenLockManager, logger: Logging = CurrentEnvironment.logger, navigation: UploadNavigating) { + init(screenLockManager: ScreenLockManaging = CurrentEnvironment.screenLockManager, logger: Logging = CurrentEnvironment.logger, navigation: UploadNavigating) { self.title = "" - self.api = api - self.database = database self.screenLockManager = screenLockManager self.logger = logger self.navigation = navigation diff --git a/Tree Tracker/Services/Airtable/AirtableAuthenticationAdapter.swift b/Tree Tracker/Services/Airtable/AirtableAuthenticationAdapter.swift new file mode 100644 index 0000000..e6123a8 --- /dev/null +++ b/Tree Tracker/Services/Airtable/AirtableAuthenticationAdapter.swift @@ -0,0 +1,18 @@ +import Foundation +import Alamofire + +class AirtableAuthenticationAdapter: RequestAdapter { + + private var apiKey: String + + init(_ apiKey: String) { + self.apiKey = apiKey + } + + func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { + var urlRequest = urlRequest + urlRequest.headers.add(.authorization(bearerToken: apiKey)) + completion(.success(urlRequest)) + } + +} diff --git a/Tree Tracker/Services/Airtable/AirtableSessionFactory.swift b/Tree Tracker/Services/Airtable/AirtableSessionFactory.swift new file mode 100644 index 0000000..a8aed94 --- /dev/null +++ b/Tree Tracker/Services/Airtable/AirtableSessionFactory.swift @@ -0,0 +1,68 @@ +import Foundation +import Alamofire + +/* + Provides request interception, including retry and authentication, for Airtable requests + */ +class AirtableSessionFactory { + + private var session: Session? + private var airtableBaseId: String + private var airtableApiKey: String + private var httpRequestTimeoutSeconds: TimeInterval + private var httpWaitsForConnectivity: Bool + private var httpRetryDelaySeconds: Int + private var httpRetryLimit: Int + private var airtableTablePrefix: String + + init(airtableBaseId: String, + airtableApiKey: String, + airtableTablePrefix: String = "", + httpRequestTimeoutSeconds: TimeInterval, + httpWaitsForConnectivity: Bool, + httpRetryDelaySeconds: Int, + httpRetryLimit: Int) { + self.airtableBaseId = airtableBaseId + self.airtableApiKey = airtableApiKey + self.airtableTablePrefix = airtableTablePrefix + self.httpRequestTimeoutSeconds = httpRequestTimeoutSeconds + self.httpWaitsForConnectivity = httpWaitsForConnectivity + self.httpRetryDelaySeconds = httpRetryDelaySeconds + self.httpRetryLimit = httpRetryLimit + } + + func get() -> Session { + if session == nil { + let sessionConfig = URLSessionConfiguration.af.default + sessionConfig.timeoutIntervalForRequest = httpRequestTimeoutSeconds + sessionConfig.waitsForConnectivity = httpWaitsForConnectivity + + let interceptor = Interceptor(adapter: AirtableAuthenticationAdapter(airtableApiKey), + retrier: RetryingRequestInterceptor(retryDelaySecs: httpRetryDelaySeconds, + maxRetries: httpRetryLimit)) + + session = Session(configuration: sessionConfig, + interceptor: interceptor) + } + return session! + } + + func baseUrl(adding: String) -> URL { + var result = URL(string: "https://api.airtable.com/v0/\(airtableBaseId)")! + result.appendPathComponent(adding) + return result + } + + func getSitesUrl() -> URL { + return baseUrl(adding: "\(airtableTablePrefix)\(Constants.Airtable.sitesTable)") + } + + func getSpeciesUrl() -> URL { + return baseUrl(adding: "\(airtableTablePrefix)\(Constants.Airtable.speciesTable)") + } + + func getSupervisorUrl() -> URL { + return baseUrl(adding: "\(airtableTablePrefix)\(Constants.Airtable.supervisorsTable)") + } + +} diff --git a/Tree Tracker/Services/Airtable/AirtableSiteService.swift b/Tree Tracker/Services/Airtable/AirtableSiteService.swift new file mode 100644 index 0000000..f1a4eed --- /dev/null +++ b/Tree Tracker/Services/Airtable/AirtableSiteService.swift @@ -0,0 +1,87 @@ +import Foundation +import Resolver +import Alamofire + +class AirtableSiteService: SiteService { + + @Injected private var database: Database + @Injected private var sessionFactory: AirtableSessionFactory + + // MARK: data publisher + // See https://swiftsenpai.com/swift/define-protocol-with-published-property-wrapper/ + @Published var sites: [Site] = [] + var sitesPublisher: Published<[Site]>.Publisher { $sites } + + // MARK: business logic + init() { + self.sync() { _ in } // fire and forget + } + + // Synchronise local cache with remote datastore + func sync(completion: @escaping (Result) -> Void) { + let request = getSession().request(sessionFactory.getSitesUrl(), + method: .get, + encoding: URLEncoding.queryString) + + request.validate().responseDecodable(decoder: JSONDecoder._iso8601ms) { [weak self] (response: DataResponse, AFError>) in + // TODO: Handle multiple pages (where number of sites > 100) + switch response.result { + case .success: + do { + let result = try response.result.get() + self?.sites.removeAll() + result.records.forEach { airtableSite in + self?.sites.append(airtableSite.toSite()) + } + self?.database.replace(self!.sites) { + completion(.success(true)) + } + } catch { + print("Unexpected error: \(error).") + } + case .failure: + completion(.failure(DataAccessError.remoteError(errorCode: response.error!.responseCode!, + errorMessage: (response.error!.errorDescription!)))) + } + } + } + + // Return sites from local cache, adding to buffer + func fetchAll(completion: @escaping (Result<[Site], DataAccessError>) -> Void) { + database.fetchAll(Site.self) { [weak self] sites in + self?.sites.removeAll() + sites.forEach() { self?.sites.append($0) } + completion(Result.success(self!.sites)) + } + } + + // Add a site to remote and trigger a sync to update local cache + func addSite(name: String, completion: @escaping (Result) -> Void) { + // build struct to represent target JSON body + let parameters: [String: [String: String]] = [ + "fields": ["Name": name] + ] + + let request = getSession().request(sessionFactory.getSitesUrl(), + method: .post, + parameters: parameters, + encoder: JSONParameterEncoder.default) + + request.validate().responseDecodable(of: AirtableSite.self, decoder: JSONDecoder._iso8601ms) { response in + switch response.result { + case .success: + self.sync { result in + completion(result) + } + case .failure: + completion(.failure(DataAccessError.remoteError(errorCode: response.error!.responseCode!, + errorMessage: (response.error!.errorDescription!)))) + } + } + } + + private func getSession() -> Session { + sessionFactory.get() + } + +} diff --git a/Tree Tracker/Services/Airtable/AirtableSpeciesService.swift b/Tree Tracker/Services/Airtable/AirtableSpeciesService.swift new file mode 100644 index 0000000..d387fdb --- /dev/null +++ b/Tree Tracker/Services/Airtable/AirtableSpeciesService.swift @@ -0,0 +1,69 @@ +import Foundation +import Resolver +import Alamofire + +class AirtableSpeciesService: SpeciesService { + + @Injected private var database: Database + @Injected private var sessionFactory: AirtableSessionFactory + + // MARK: data publisher + // See https://swiftsenpai.com/swift/define-protocol-with-published-property-wrapper/ + @Published var species: [Species] = [] + var speciesPublisher: Published<[Species]>.Publisher { $species } + + // MARK: business logic + init() { + self.sync() { _ in } // fire and forget + } + + // Synchronise local cache with remote datastore + func sync(completion: @escaping (Result) -> Void) { + let request = getSession().request(sessionFactory.getSpeciesUrl(), + method: .get, + encoding: URLEncoding.queryString) + + request.validate().responseDecodable(decoder: JSONDecoder._iso8601ms) { [weak self] (response: DataResponse, AFError>) in + // TODO: Handle multiple pages (where number of species > 100) + switch response.result { + case .success: + do { + let result = try response.result.get() + self?.species.removeAll() + result.records.forEach { airtableSpecies in + self?.species.append(airtableSpecies.toSpecies()) + } + self?.database.replace(self!.species) { + completion(.success(true)) + } + } catch { + print("Unexpected error: \(error).") + } + case .failure: + completion(.failure(DataAccessError.remoteError(errorCode: response.error!.responseCode!, + errorMessage: (response.error!.errorDescription!)))) + } + } + } + + // Return species from local cache + func fetchAll(completion: @escaping (Result<[Species], DataAccessError>) -> Void) { + database.fetchAll(Species.self) { [weak self] species in + self?.species.removeAll() + species.forEach() { self?.species.append($0) } + completion(Result.success(self!.species)) + } + } + + // Add a species to remote and trigger a sync to update local cache + func addSpecies(name: String, completion: @escaping (Result) -> Void) { + fatalError("addSpecies(name:, completion:) has not been implemented") + } + + private func getSession() -> Session { + sessionFactory.get() + } + +} + + diff --git a/Tree Tracker/Services/Airtable/AirtableSupervisorService.swift b/Tree Tracker/Services/Airtable/AirtableSupervisorService.swift new file mode 100644 index 0000000..6ccd5ec --- /dev/null +++ b/Tree Tracker/Services/Airtable/AirtableSupervisorService.swift @@ -0,0 +1,67 @@ +import Foundation +import Resolver +import Alamofire + +class AirtableSupervisorService: SupervisorService { + + @Injected private var database: Database + @Injected private var sessionFactory: AirtableSessionFactory + + // MARK: data publisher + // See https://swiftsenpai.com/swift/define-protocol-with-published-property-wrapper/ + @Published var supervisors: [Supervisor] = [] + var supervisorPublisher: Published<[Supervisor]>.Publisher { $supervisors } + + // MARK: business logic + init() { + self.sync() { _ in } // fire and forget + } + + // Synchronise local cache with remote datastore + func sync(completion: @escaping (Result) -> Void) { + let request = getSession().request(sessionFactory.getSupervisorUrl(), + method: .get, + encoding: URLEncoding.queryString) + + request.validate().responseDecodable(decoder: JSONDecoder._iso8601ms) { [weak self] (response: DataResponse, AFError>) in + // TODO: Handle multiple pages (where number of entries > 100) + switch response.result { + case .success: + do { + let result = try response.result.get() + self?.supervisors.removeAll() + result.records.forEach { record in + self?.supervisors.append(record.toSupervisor()) + } + self?.database.replace(self!.supervisors) { + completion(.success(true)) + } + } catch { + print("Unexpected error: \(error).") + } + case .failure: + completion(.failure(DataAccessError.remoteError(errorCode: response.error!.responseCode!, + errorMessage: (response.error!.errorDescription!)))) + } + } + } + + // Return data from local cache, adding to buffer + func fetchAll(completion: @escaping (Result<[Supervisor], DataAccessError>) -> Void) { + database.fetchAll(Supervisor.self) { [weak self] supervisor in + self?.supervisors.removeAll() + supervisor.forEach() { self?.supervisors.append($0) } + completion(Result.success(self!.supervisors)) + } + } + + // Add a record to remote and trigger a sync to update local cache + func addSupervisor(name: String, completion: @escaping (Result) -> Void) { + fatalError("addSupervisor(name:, completion:) has not been implemented") + } + + private func getSession() -> Session { + sessionFactory.get() + } + +} diff --git a/Tree Tracker/Services/MockApi.swift b/Tree Tracker/Services/MockApi.swift index 029e4de..978122b 100644 --- a/Tree Tracker/Services/MockApi.swift +++ b/Tree Tracker/Services/MockApi.swift @@ -69,7 +69,9 @@ final class MockApi: Api { } func addSite(name: String, completion: @escaping (Result) -> Void) { - // TODO: implement test + delay { + completion(.success(self.sites[0])) + } } private func delay(completion: @escaping () -> Void) { diff --git a/Tree Tracker/Services/SiteService.swift b/Tree Tracker/Services/SiteService.swift new file mode 100644 index 0000000..8d3097a --- /dev/null +++ b/Tree Tracker/Services/SiteService.swift @@ -0,0 +1,10 @@ +import Foundation +import Alamofire + +protocol SiteService { + // See https://swiftsenpai.com/swift/define-protocol-with-published-property-wrapper/ + var sitesPublisher: Published<[Site]>.Publisher { get } + func fetchAll(completion: @escaping (Result<[Site], DataAccessError>) -> Void) + func addSite(name: String, completion: @escaping (Result) -> Void) + func sync(completion: @escaping (Result) -> Void) +} diff --git a/Tree Tracker/Services/SpeciesService.swift b/Tree Tracker/Services/SpeciesService.swift new file mode 100644 index 0000000..d81d0ad --- /dev/null +++ b/Tree Tracker/Services/SpeciesService.swift @@ -0,0 +1,10 @@ +import Foundation +import Alamofire + +protocol SpeciesService { + // See https://swiftsenpai.com/swift/define-protocol-with-published-property-wrapper/ + var speciesPublisher: Published<[Species]>.Publisher { get } + func fetchAll(completion: @escaping (Result<[Species], DataAccessError>) -> Void) + func addSpecies(name: String, completion: @escaping (Result) -> Void) + func sync(completion: @escaping (Result) -> Void) +} diff --git a/Tree Tracker/Services/SupervisorService.swift b/Tree Tracker/Services/SupervisorService.swift new file mode 100644 index 0000000..e1de881 --- /dev/null +++ b/Tree Tracker/Services/SupervisorService.swift @@ -0,0 +1,10 @@ +import Foundation +import Alamofire + +protocol SupervisorService { + // See https://swiftsenpai.com/swift/define-protocol-with-published-property-wrapper/ + var supervisorPublisher: Published<[Supervisor]>.Publisher { get } + func fetchAll(completion: @escaping (Result<[Supervisor], DataAccessError>) -> Void) + func addSupervisor(name: String, completion: @escaping (Result) -> Void) + func sync(completion: @escaping (Result) -> Void) +} diff --git a/Tree Tracker/Utilities/Environment.swift b/Tree Tracker/Utilities/Environment.swift index b6c04c2..72a8237 100644 --- a/Tree Tracker/Utilities/Environment.swift +++ b/Tree Tracker/Utilities/Environment.swift @@ -2,8 +2,6 @@ import Foundation import class Photos.PHCachingImageManager struct Environment { - let api: Api - let database: Database let defaults: Defaults let imageCache: ImageCaching let screenLockManager: ScreenLockManaging @@ -15,11 +13,8 @@ struct Environment { let CurrentEnvironment: Environment = { let logger = Logger(output: .print) let defaults = Defaults() - let shouldUseMockServer = CommandLine.arguments.contains("--mock-server") return Environment( - api: shouldUseMockServer ? MockApi() : AlamofireApi(logger: logger), - database: Database(logger: logger), defaults: defaults, imageCache: GRDBImageCache(logger: logger), screenLockManager: UIScreenLockManager(), diff --git a/Tree Tracker/Utilities/URLImageLoader.swift b/Tree Tracker/Utilities/URLImageLoader.swift index 2eb3884..88b3977 100644 --- a/Tree Tracker/Utilities/URLImageLoader.swift +++ b/Tree Tracker/Utilities/URLImageLoader.swift @@ -1,6 +1,7 @@ import Foundation import class UIKit.UIImage import struct CoreGraphics.CGSize +import Resolver fileprivate extension LogCategory { static var imageLoader = LogCategory(name: "ImageLoader") @@ -13,13 +14,13 @@ final class URLImageLoader: ImageLoader { return url } - private let api: Api + @Injected private var api: Api + private let thumbnailsImageCache: ImageCaching private let logger: Logging - init(url: String, api: Api = CurrentEnvironment.api, thumbnailsImageCache: ImageCaching = CurrentEnvironment.imageCache, logger: Logging = CurrentEnvironment.logger) { + init(url: String, thumbnailsImageCache: ImageCaching = CurrentEnvironment.imageCache, logger: Logging = CurrentEnvironment.logger) { self.url = url - self.api = api self.thumbnailsImageCache = thumbnailsImageCache self.logger = logger } diff --git a/Unit Tests/AirtableSiteServiceTests.swift b/Unit Tests/AirtableSiteServiceTests.swift new file mode 100644 index 0000000..b478c06 --- /dev/null +++ b/Unit Tests/AirtableSiteServiceTests.swift @@ -0,0 +1,166 @@ +@testable import Tree_Tracker +import Foundation +import Resolver +import XCTest +import Combine + +class AirtableSiteServiceTests: XCTestCase { + + @Injected private var siteService: SiteService + @Injected private var sessionFactory: AirtableSessionFactory + + private var newSiteName: String = UUID.init().uuidString + private let DEFAULT_EXPECTATION_TIMEOUT = TimeInterval(5) + private var cancellables: Set = [] + private var deleteQueue: [Site] = [] + + override func setUp() { + let expectation = expectation(description: "Sync()") + siteService.sync() { _ in + expectation.fulfill() + } + waitForExpectations(timeout: 5) + } + + override func tearDown() { + // Delete the sites from Airtable otherwise these will accumulate!! + let session = sessionFactory.get() + if deleteQueue.isNotEmpty { + deleteQueue.forEach { site in + var siteUrl = sessionFactory.getSitesUrl() + siteUrl.appendPathComponent(site.id) + let request = session.request(siteUrl, + method: .delete) + + let expectation = expectation(description: "Site deletion") + + request.validate().response { _ in + print("Deleted site \(site.id)") + expectation.fulfill() + } + } + waitForExpectations(timeout: 5) + } + } + + func test_sut_available() { + XCTAssertNotNil(siteService) + } + + func test_fetchAll() { + let expectation = expectation(description: "Get sites") + siteService.fetchAll() { result in + expectation.fulfill() + do { + let sites = try result.get() + XCTAssertTrue( sites.isNotEmpty ) + XCTAssertTrue( sites.count > 1 ) + } catch { + XCTFail("Error fetching sites: \(error)") + } + } + waitForExpectations(timeout: DEFAULT_EXPECTATION_TIMEOUT) + } + + func test_fetchAll_andAdd() { + + var initialSites: [Site] = [] + var newSites: [Site] = [] + + // expect and wait for fetching sites before add + let getInitialExpectation = expectation(description: "Get initial sites list") + + siteService.fetchAll() { result in + getInitialExpectation.fulfill() + do { + initialSites = try result.get() + } catch { + XCTFail("Error fetching sites: \(error)") + } + } + + waitForExpectations(timeout: DEFAULT_EXPECTATION_TIMEOUT) + + // expect and wait for addition of new site + let addSiteExpectation = expectation(description: "Add site") + + siteService.addSite(name: newSiteName) { _ in + addSiteExpectation.fulfill() + } + + waitForExpectations(timeout: DEFAULT_EXPECTATION_TIMEOUT) + + // expect and wait for the results of a subsequent fetch of sites + let getUpdatedExpectation = expectation(description: "Get updated sites list") + + siteService.fetchAll() { result in + getUpdatedExpectation.fulfill() + do { + newSites = try result.get() + } catch { + XCTFail("Error fetching sites: \(error)") + } + } + + waitForExpectations(timeout: DEFAULT_EXPECTATION_TIMEOUT) + + XCTAssertNotNil(newSites) + // new sites should be 1 more than original list + XCTAssertTrue(newSites.count == (initialSites.count + 1)) + // and should contain a site with our new name + let newSite = newSites.first(where: { $0.name == newSiteName }) + XCTAssertNotNil(newSite) + + deleteQueue.append(newSite!) + } + + func test_addPublishesUpdatedSitesList() { + + var initialSites: [Site] = [] + var newPublishedSites: [Site] = [] + + // expect and wait for fetching sites before add + let getInitialExpectation = expectation(description: "Get initial sites list") + + siteService.fetchAll() { result in + getInitialExpectation.fulfill() + do { + initialSites = try result.get() + } catch { + XCTFail("Error fetching sites: \(error)") + } + } + + waitForExpectations(timeout: DEFAULT_EXPECTATION_TIMEOUT) + + let publisherExpectation = expectation(description: "Add triggers publisher") + + // capture publishing of updated sites (happens later!) + siteService.sitesPublisher.sink() { sites in + if ( sites.count == (initialSites.count + 1) && + sites.contains(where: { self.newSiteName == $0.name })) { + newPublishedSites = sites + // We have fulfilled the expectation that sites list should be increased + // and should contain a site with our new name + publisherExpectation.fulfill() + } + }.store(in: &cancellables) + + // now add the new site, fire and forget style + let addExpectation = expectation(description: "Add site") + siteService.addSite(name: newSiteName, completion: { _ in + addExpectation.fulfill() + }) + + // wait for the add site and publisher expectations (getInitial having already been fulfilled) + // note that this is effectively also an assertion + waitForExpectations(timeout: DEFAULT_EXPECTATION_TIMEOUT) + + // and should also contain a site with our new name + let newSite = newPublishedSites.first(where: { $0.name == newSiteName }) + XCTAssertNotNil(newSite) + + deleteQueue.append(newSite!) + } + +} diff --git a/Unit Tests/Info.plist b/Unit Tests/Info.plist index 25d2d28..93c38de 100644 --- a/Unit Tests/Info.plist +++ b/Unit Tests/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.8.0 + 0.8.1 CFBundleVersion 1