diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index a73041e96e8..1ae75afb041 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -552,6 +552,7 @@ CC0786C7267BB10700BA9AC1 /* shipping-label-status-success.json in Resources */ = {isa = PBXBuildFile; fileRef = CC0786C6267BB10700BA9AC1 /* shipping-label-status-success.json */; }; CC0786C9267BB32800BA9AC1 /* ShippingLabelStatusMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0786C8267BB32800BA9AC1 /* ShippingLabelStatusMapperTests.swift */; }; CC6A1FF5270E042200F6AF4A /* OrderMetaData.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6A1FF4270E042200F6AF4A /* OrderMetaData.swift */; }; + CC80E3F92948C8BC00D5FF45 /* site-visits-quarter.json in Resources */ = {isa = PBXBuildFile; fileRef = CC80E3F82948C8BC00D5FF45 /* site-visits-quarter.json */; }; CC851D0625E51ADF00249E9C /* Decimal+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC851D0525E51ADF00249E9C /* Decimal+Extensions.swift */; }; CC851D1425E52AB500249E9C /* Decimal+ExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC851D1325E52AB500249E9C /* Decimal+ExtensionsTests.swift */; }; CC9A24A32641BCF2005DE56E /* ShippingLabelCreationEligibilityResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC9A24A22641BCF2005DE56E /* ShippingLabelCreationEligibilityResponse.swift */; }; @@ -1314,6 +1315,7 @@ CC0786C6267BB10700BA9AC1 /* shipping-label-status-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "shipping-label-status-success.json"; sourceTree = ""; }; CC0786C8267BB32800BA9AC1 /* ShippingLabelStatusMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelStatusMapperTests.swift; sourceTree = ""; }; CC6A1FF4270E042200F6AF4A /* OrderMetaData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderMetaData.swift; sourceTree = ""; }; + CC80E3F82948C8BC00D5FF45 /* site-visits-quarter.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "site-visits-quarter.json"; sourceTree = ""; }; CC851D0525E51ADF00249E9C /* Decimal+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decimal+Extensions.swift"; sourceTree = ""; }; CC851D1325E52AB500249E9C /* Decimal+ExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decimal+ExtensionsTests.swift"; sourceTree = ""; }; CC9A24A22641BCF2005DE56E /* ShippingLabelCreationEligibilityResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelCreationEligibilityResponse.swift; sourceTree = ""; }; @@ -2213,6 +2215,7 @@ 74A1D25F211898F000931DFA /* site-visits-day.json */, 74A1D260211898F000931DFA /* site-visits-week.json */, 74A1D261211898F000931DFA /* site-visits-month.json */, + CC80E3F82948C8BC00D5FF45 /* site-visits-quarter.json */, 74A1D262211898F000931DFA /* site-visits-year.json */, 743BF8BD21191B63008A9D87 /* site-visits.json */, 74E30950216E8DCE00ABCE4C /* site-visits-alt.json */, @@ -2942,6 +2945,7 @@ 45AB8B2024AB3E1F00B5B36E /* product-tags-empty.json in Resources */, 0359EA2127AAE58C0048DE2D /* wcpay-charge-card-present.json in Resources */, 451274A625276C82009911FF /* product-variation.json in Resources */, + CC80E3F92948C8BC00D5FF45 /* site-visits-quarter.json in Resources */, 020D07C223D858BB00FD9580 /* media-upload.json in Resources */, 31A451D127863A2E00FE81AA /* stripe-account-rejected-other.json in Resources */, 74ABA1CA213F19FE00FFAD30 /* top-performers-year.json in Resources */, diff --git a/Networking/Networking/Model/Stats/SiteSummaryStats.swift b/Networking/Networking/Model/Stats/SiteSummaryStats.swift index 64897f89a8e..fbe2520084e 100644 --- a/Networking/Networking/Model/Stats/SiteSummaryStats.swift +++ b/Networking/Networking/Model/Stats/SiteSummaryStats.swift @@ -3,7 +3,7 @@ import Codegen /// Represents site summary stats for a specific period. /// -public struct SiteSummaryStats: Decodable, GeneratedCopiable, GeneratedFakeable { +public struct SiteSummaryStats: Decodable, Equatable, GeneratedCopiable, GeneratedFakeable { public let siteID: Int64 public let date: String public let period: StatGranularity diff --git a/Networking/NetworkingTests/Responses/site-visits-quarter.json b/Networking/NetworkingTests/Responses/site-visits-quarter.json new file mode 100644 index 00000000000..97e0deafce3 --- /dev/null +++ b/Networking/NetworkingTests/Responses/site-visits-quarter.json @@ -0,0 +1,26 @@ +{ + "date": "2022-12-09", + "unit": "month", + "fields": [ + "period", + "views", + "visitors" + ], + "data": [ + [ + "2022-10-01", + 448, + 224 + ], + [ + "2022-11-01", + 14, + 7 + ], + [ + "2022-12-01", + 24, + 12 + ] + ] +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Stats v4/StoreStatsAndTopPerformersViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Stats v4/StoreStatsAndTopPerformersViewController.swift index 1bcb409dd4b..57e9cc1feff 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Stats v4/StoreStatsAndTopPerformersViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Stats v4/StoreStatsAndTopPerformersViewController.swift @@ -483,7 +483,7 @@ private extension StoreStatsAndTopPerformersViewController { } } - func handleSiteVisitStatsStoreError(error: SiteVisitStatsStoreError) { + func handleSiteStatsStoreError(error: SiteStatsStoreError) { switch error { case .noPermission: updateSiteVisitors(mode: .hidden) @@ -501,8 +501,8 @@ private extension StoreStatsAndTopPerformersViewController { private func handleSyncError(error: Error) { switch error { - case let siteVisitStatsStoreError as SiteVisitStatsStoreError: - handleSiteVisitStatsStoreError(error: siteVisitStatsStoreError) + case let siteStatsStoreError as SiteStatsStoreError: + handleSiteStatsStoreError(error: siteStatsStoreError) default: displaySyncingError() } diff --git a/Yosemite/Yosemite/Actions/StatsActionV4.swift b/Yosemite/Yosemite/Actions/StatsActionV4.swift index a7ed090c0be..b310688c492 100644 --- a/Yosemite/Yosemite/Actions/StatsActionV4.swift +++ b/Yosemite/Yosemite/Actions/StatsActionV4.swift @@ -48,4 +48,12 @@ public enum StatsActionV4: Action { forceRefresh: Bool, saveInStorage: Bool, onCompletion: (Result) -> Void) + + /// Retrieves the site summary stats for the provided site ID, period(s), and date, without saving them to the Storage layer. + /// + case retrieveSiteSummaryStats(siteID: Int64, + period: StatGranularity, + quantity: Int, + latestDateToInclude: Date, + onCompletion: (Result) -> Void) } diff --git a/Yosemite/Yosemite/Stores/StatsStoreV4.swift b/Yosemite/Yosemite/Stores/StatsStoreV4.swift index cb85eefbe4b..c7500a2eece 100644 --- a/Yosemite/Yosemite/Stores/StatsStoreV4.swift +++ b/Yosemite/Yosemite/Stores/StatsStoreV4.swift @@ -6,13 +6,13 @@ import WooFoundation // MARK: - StatsStoreV4 // public final class StatsStoreV4: Store { - private let siteVisitStatsRemote: SiteStatsRemote + private let siteStatsRemote: SiteStatsRemote private let leaderboardsRemote: LeaderboardsRemote private let orderStatsRemote: OrderStatsRemoteV4 private let productsRemote: ProductsRemote public override init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network) { - self.siteVisitStatsRemote = SiteStatsRemote(network: network) + self.siteStatsRemote = SiteStatsRemote(network: network) self.leaderboardsRemote = LeaderboardsRemote(network: network) self.orderStatsRemote = OrderStatsRemoteV4(network: network) self.productsRemote = ProductsRemote(network: network) @@ -90,6 +90,16 @@ public final class StatsStoreV4: Store { forceRefresh: forceRefresh, saveInStorage: saveInStorage, onCompletion: onCompletion) + case .retrieveSiteSummaryStats(let siteID, + let period, + let quantity, + let latestDateToInclude, + let onCompletion): + retrieveSiteSummaryStats(siteID: siteID, + period: period, + quantity: quantity, + latestDateToInclude: latestDateToInclude, + onCompletion: onCompletion) } } } @@ -164,7 +174,7 @@ private extension StatsStoreV4 { let quantity = timeRange.siteVisitStatsQuantity(date: latestDateToInclude, siteTimezone: siteTimezone) - siteVisitStatsRemote.loadSiteVisitorStats(for: siteID, + siteStatsRemote.loadSiteVisitorStats(for: siteID, siteTimezone: siteTimezone, unit: timeRange.siteVisitStatsGranularity, latestDateToInclude: latestDateToInclude, @@ -174,7 +184,50 @@ private extension StatsStoreV4 { self?.upsertStoredSiteVisitStats(readOnlyStats: siteVisitStats, timeRange: timeRange) onCompletion(.success(())) case .failure(let error): - onCompletion(.failure(SiteVisitStatsStoreError(error: error))) + onCompletion(.failure(SiteStatsStoreError(error: error))) + } + } + } + + /// Retrieves the site summary stats for the provided site ID, period(s), and date, without saving them to the Storage layer. + /// + func retrieveSiteSummaryStats(siteID: Int64, + period: StatGranularity, + quantity: Int, + latestDateToInclude: Date, + onCompletion: @escaping (Result) -> Void) { + if quantity == 1 { + siteStatsRemote.loadSiteSummaryStats(for: siteID, + period: period, + includingDate: latestDateToInclude) { result in + switch result { + case .success(let siteSummaryStats): + onCompletion(.success(siteSummaryStats)) + case .failure(let error): + onCompletion(.failure(SiteStatsStoreError(error: error))) + } + } + } else { + // If we are not fetching stats for a single period, we need to summarize the stats manually. + // The remote summary stats endpoint only retrieves visitor stats for a single period. + // We should only do this for periods of a month or greater; otherwise the visitor total is inaccurate. + // See: pe5uwI-5c-p2 + siteStatsRemote.loadSiteVisitorStats(for: siteID, + unit: period, + latestDateToInclude: latestDateToInclude, + quantity: quantity) { result in + switch result { + case .success(let siteVisitStats): + let totalViews = siteVisitStats.items?.map({ $0.views }).reduce(0, +) ?? 0 + let summaryStats = SiteSummaryStats(siteID: siteID, + date: siteVisitStats.date, + period: siteVisitStats.granularity, + visitors: siteVisitStats.totalVisitors, + views: totalViews) + onCompletion(.success(summaryStats)) + case .failure(let error): + onCompletion(.failure(SiteStatsStoreError(error: error))) + } } } } @@ -571,7 +624,7 @@ public enum StatsStoreV4Error: Error { /// - statsModuleDisabled: Jetpack site stats module is disabled for the site. /// - unknown: other error cases. /// -public enum SiteVisitStatsStoreError: Error { +public enum SiteStatsStoreError: Error { case statsModuleDisabled case noPermission case unknown diff --git a/Yosemite/YosemiteTests/Stores/SiteVisitStatsStoreErrorTests.swift b/Yosemite/YosemiteTests/Stores/SiteVisitStatsStoreErrorTests.swift index 2945d66db73..077bc50a692 100644 --- a/Yosemite/YosemiteTests/Stores/SiteVisitStatsStoreErrorTests.swift +++ b/Yosemite/YosemiteTests/Stores/SiteVisitStatsStoreErrorTests.swift @@ -2,28 +2,28 @@ import XCTest @testable import Yosemite @testable import Networking -class SiteVisitStatsStoreErrorTests: XCTestCase { +class SiteStatsStoreErrorTests: XCTestCase { func testNoPermissionError() { let remoteError = DotcomError.noStatsPermission - let error = SiteVisitStatsStoreError(error: remoteError) + let error = SiteStatsStoreError(error: remoteError) XCTAssertEqual(error, .noPermission) } func testStatsModuleDisabledError() { let remoteError = DotcomError.statsModuleDisabled - let error = SiteVisitStatsStoreError(error: remoteError) + let error = SiteStatsStoreError(error: remoteError) XCTAssertEqual(error, .statsModuleDisabled) } func testOtherDotcomError() { let remoteError = DotcomError.unknown(code: "invalid_blog", message: "This blog does not have Jetpack connected") - let error = SiteVisitStatsStoreError(error: remoteError) + let error = SiteStatsStoreError(error: remoteError) XCTAssertEqual(error, .unknown) } func testNonDotcomRemoteError() { let remoteError = NSError(domain: "Woo", code: 404, userInfo: nil) - let error = SiteVisitStatsStoreError(error: remoteError) + let error = SiteStatsStoreError(error: remoteError) XCTAssertEqual(error, .unknown) } } diff --git a/Yosemite/YosemiteTests/Stores/StatsStoreV4Tests.swift b/Yosemite/YosemiteTests/Stores/StatsStoreV4Tests.swift index af91cde7bfb..18cea650d0c 100644 --- a/Yosemite/YosemiteTests/Stores/StatsStoreV4Tests.swift +++ b/Yosemite/YosemiteTests/Stores/StatsStoreV4Tests.swift @@ -503,6 +503,124 @@ final class StatsStoreV4Tests: XCTestCase { let storageTopEarnerStats = viewStorage.loadTopEarnerStats(date: "2020", granularity: StatGranularity.year.rawValue) XCTAssertEqual(storageTopEarnerStats?.toReadOnly(), expectedTopEarnerStats) } + + // MARK: - StatsStoreV4.retrieveSiteSummaryStats + + /// Verifies that `StatsActionV4.retrieveSiteSummaryStats` returns any retrieved SiteSummaryStats. + /// + func test_retrieveSiteSummaryStats_returns_retrieved_stats() throws { + // Given + let store = StatsStoreV4(dispatcher: dispatcher, storageManager: storageManager, network: network) + network.simulateResponse(requestUrlSuffix: "sites/\(sampleSiteID)/stats/summary/", filename: "site-summary-stats") + + // When + let result: Result = waitFor { promise in + let action = StatsActionV4.retrieveSiteSummaryStats(siteID: self.sampleSiteID, + period: .day, + quantity: 1, + latestDateToInclude: DateFormatter.dateFromString(with: "2022-12-09T17:06:55")) { result in + promise(result) + } + store.onAction(action) + } + + // Then + XCTAssertTrue(result.isSuccess) + let siteSummaryStats = try XCTUnwrap(result).get() + XCTAssertEqual(siteSummaryStats, sampleSiteSummaryStats()) + } + + /// Verifies that `StatsActionV4.retrieveSiteSummaryStats` makes the expected alternate network request for multiple stats periods. + /// + func test_retrieveSiteSummaryStats_makes_expected_network_request_for_multiple_periods() throws { + // Given + let store = StatsStoreV4(dispatcher: dispatcher, storageManager: storageManager, network: network) + + // When + let _: Void = waitFor { promise in + let action = StatsActionV4.retrieveSiteSummaryStats(siteID: self.sampleSiteID, + period: .month, + quantity: 3, + latestDateToInclude: DateFormatter.dateFromString(with: "2022-12-31T17:06:55")) { _ in + promise(()) + } + store.onAction(action) + } + + // Then + let request = try XCTUnwrap(network.requestsForResponseData.first as? DotcomRequest) + XCTAssertEqual(request.path, "sites/\(sampleSiteID)/stats/visits/") + XCTAssertEqual(request.parameters?["date"] as? String, "2022-12-31") + XCTAssertEqual(request.parameters?["unit"] as? String, "month") + XCTAssertEqual(request.parameters?["quantity"] as? String, "3") + } + + /// Verifies that `StatsActionV4.retrieveSiteSummaryStats` converts and returns SiteSummaryStats for multiple periods. + /// + func test_retrieveSiteSummaryStats_returns_retrieved_quarter_stats() throws { + // Given + let store = StatsStoreV4(dispatcher: dispatcher, storageManager: storageManager, network: network) + network.simulateResponse(requestUrlSuffix: "sites/\(sampleSiteID)/stats/visits/", filename: "site-visits-quarter") + + // When + let result: Result = waitFor { promise in + let action = StatsActionV4.retrieveSiteSummaryStats(siteID: self.sampleSiteID, + period: .month, + quantity: 3, + latestDateToInclude: DateFormatter.dateFromString(with: "2022-12-31T17:06:55")) { result in + promise(result) + } + store.onAction(action) + } + + // Then + XCTAssertTrue(result.isSuccess) + let siteSummaryStats = try XCTUnwrap(result).get() + XCTAssertEqual(siteSummaryStats, sampleSiteSummaryStatsQuarter()) + } + + /// Verifies that `StatsActionV4.retrieveSiteSummaryStats` returns an error whenever there is an error response from the backend. + /// + func test_retrieveSiteSummaryStats_returns_error_upon_response_error() { + // Given + let store = StatsStoreV4(dispatcher: dispatcher, storageManager: storageManager, network: network) + network.simulateResponse(requestUrlSuffix: "sites/\(sampleSiteID)/stats/summary/", filename: "generic_error") + + // When + let result: Result = waitFor { promise in + let action = StatsActionV4.retrieveSiteSummaryStats(siteID: self.sampleSiteID, + period: .day, + quantity: 1, + latestDateToInclude: DateFormatter.dateFromString(with: "2022-12-09T17:06:55")) { result in + promise(result) + } + store.onAction(action) + } + + // Then + XCTAssertTrue(result.isFailure) + } + + /// Verifies that `StatsActionV4.retrieveSiteSummaryStats` returns an error whenever there is no backend response. + /// + func test_retrieveSiteSummaryStats_returns_error_upon_empty_response() { + // Given + let store = StatsStoreV4(dispatcher: dispatcher, storageManager: storageManager, network: network) + + // When + let result: Result = waitFor { promise in + let action = StatsActionV4.retrieveSiteSummaryStats(siteID: self.sampleSiteID, + period: .day, + quantity: 1, + latestDateToInclude: DateFormatter.dateFromString(with: "2022-12-09T17:06:55")) { result in + promise(result) + } + store.onAction(action) + } + + // Then + XCTAssertTrue(result.isFailure) + } } @@ -683,4 +801,22 @@ private extension StatsStoreV4Tests { currency: "", imageUrl: "https://dulces.mystagingwebsite.com/wp-content/uploads/2020/07/img_7472-scaled.jpeg") } + + // MARK: - Site Summary Stats Sample + + func sampleSiteSummaryStats() -> Networking.SiteSummaryStats { + return SiteSummaryStats(siteID: sampleSiteID, + date: "2022-12-09", + period: .day, + visitors: 12, + views: 123) + } + + func sampleSiteSummaryStatsQuarter() -> Networking.SiteSummaryStats { + return SiteSummaryStats(siteID: sampleSiteID, + date: "2022-12-09", + period: .month, + visitors: 243, + views: 486) + } }