Skip to content

Commit 9dbf8d5

Browse files
authored
Merge pull request #7586 from woocommerce/feat/7034-leaderboards-products-api
Support `/wc-analytics/leaderboards/products` endpoint
2 parents 3c84760 + 3448571 commit 9dbf8d5

File tree

5 files changed

+210
-41
lines changed

5 files changed

+210
-41
lines changed

Networking/Networking/Network/MockNetwork.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,10 @@ private extension MockNetwork {
165165
return responseQueue[keyAndQueue.key]?.dequeue()
166166
}
167167
} else {
168-
if let filename = responseMap.filter({ searchPath.hasSuffix($0.key) }).first?.value {
168+
if let filename = responseMap.filter({ searchPath.hasSuffix($0.key) })
169+
// In cases where a suffix is a substring of another suffix, the longer suffix is preferred in matched results.
170+
.sorted(by: { $0.key.count > $1.key.count })
171+
.first?.value {
169172
return filename
170173
}
171174
}

Networking/Networking/Remote/LeaderboardsRemote.swift

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,42 @@ public class LeaderboardsRemote: Remote {
2929
let mapper = LeaderboardListMapper()
3030
enqueue(request, mapper: mapper, completion: completion)
3131
}
32+
33+
/// Fetch the leaderboards with the deprecated API for a given site under WooCommerce version 6.7,
34+
/// depending on the given granularity of the `unit` parameter.
35+
///
36+
/// - Parameters:
37+
/// - siteID: The site ID
38+
/// - unit: Defines the granularity of the stats we are fetching (one of 'hour', 'day', 'week', 'month', or 'year')
39+
/// - earliestDateToInclude: The earliest date to include in the results. This string is ISO8601 compliant
40+
/// - latestDateToInclude: The latest date to include in the results. This string is ISO8601 compliant
41+
/// - quantity: Number of results to fetch
42+
/// - completion: Closure to be executed upon completion
43+
///
44+
public func loadLeaderboardsDeprecated(for siteID: Int64,
45+
unit: StatsGranularityV4,
46+
earliestDateToInclude: String,
47+
latestDateToInclude: String,
48+
quantity: Int,
49+
completion: @escaping (Result<[Leaderboard], Error>) -> Void) {
50+
let parameters = [ParameterKeys.interval: unit.rawValue,
51+
ParameterKeys.after: earliestDateToInclude,
52+
ParameterKeys.before: latestDateToInclude,
53+
ParameterKeys.quantity: String(quantity)]
54+
55+
let request = JetpackRequest(wooApiVersion: .wcAnalytics, method: .get, siteID: siteID, path: Constants.pathDeprecated, parameters: parameters)
56+
let mapper = LeaderboardListMapper()
57+
enqueue(request, mapper: mapper, completion: completion)
58+
}
3259
}
3360

3461

3562
// MARK: - Constants
3663
//
3764
private extension LeaderboardsRemote {
3865
enum Constants {
39-
static let path = "leaderboards"
66+
static let pathDeprecated = "leaderboards"
67+
static let path = "leaderboards/products"
4068
}
4169

4270
enum ParameterKeys {

Networking/NetworkingTests/Remote/LeaderboardsRemoteTests.swift

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,70 @@ import XCTest
55
///
66
final class LeaderboardsRemoteV4Tests: XCTestCase {
77

8-
let network = MockNetwork()
9-
let sampleSiteID: Int64 = 1234
8+
private let network = MockNetwork()
9+
private let sampleSiteID: Int64 = 1234
1010

1111
override func setUp() {
1212
super.setUp()
1313
network.removeAllSimulatedResponses()
1414
}
1515

16-
func testLeaderboardReturnsCorrectParsedValues() throws {
16+
func test_leaderboard_returns_correctly_parsed_values() throws {
1717
// Given
1818
let remote = LeaderboardsRemote(network: network)
19-
network.simulateResponse(requestUrlSuffix: "leaderboards", filename: "leaderboards-year")
19+
network.simulateResponse(requestUrlSuffix: "leaderboards/products", filename: "leaderboards-year")
2020

2121
// When
22-
var remoteResult: Result<[Leaderboard], Error>?
23-
waitForExpectation { exp in
24-
remote.loadLeaderboards(for: sampleSiteID,
22+
let result = waitFor { promise in
23+
remote.loadLeaderboards(for: self.sampleSiteID,
2524
unit: .yearly,
2625
earliestDateToInclude: "2020-01-01T00:00:00",
2726
latestDateToInclude: "2020-12-31T23:59:59",
2827
quantity: 3) { result in
29-
remoteResult = result
30-
exp.fulfill()
28+
promise(result)
29+
}
30+
}
31+
32+
// Then
33+
let leaderboards = try XCTUnwrap(result.get())
34+
35+
// API Returns 4 leaderboards
36+
XCTAssertEqual(leaderboards.count, 4)
37+
38+
// The 4th leaderboard contains the top products and should not be empty
39+
let topProducts = leaderboards[3]
40+
XCTAssertFalse(topProducts.rows.isEmpty)
41+
42+
// Each product should have non-empty values
43+
let expectedValues = [(quantity: 4, total: 20000.0), (quantity: 1, total: 15.99)]
44+
zip(topProducts.rows, expectedValues).forEach { product, expectedValue in
45+
XCTAssertFalse(product.subject.display.isEmpty)
46+
XCTAssertFalse(product.subject.value.isEmpty)
47+
XCTAssertFalse(product.quantity.display.isEmpty)
48+
XCTAssertEqual(product.quantity.value, expectedValue.quantity)
49+
XCTAssertFalse(product.total.display.isEmpty)
50+
XCTAssertEqual(product.total.value, expectedValue.total)
51+
}
52+
}
53+
54+
func test_leaderboardDeprecated_returns_correctly_parsed_values() throws {
55+
// Given
56+
let remote = LeaderboardsRemote(network: network)
57+
network.simulateResponse(requestUrlSuffix: "leaderboards", filename: "leaderboards-year")
58+
59+
// When
60+
let result = waitFor { promise in
61+
remote.loadLeaderboardsDeprecated(for: self.sampleSiteID,
62+
unit: .yearly,
63+
earliestDateToInclude: "2020-01-01T00:00:00",
64+
latestDateToInclude: "2020-12-31T23:59:59",
65+
quantity: 3) { result in
66+
promise(result)
3167
}
3268
}
3369

3470
// Then
35-
let leaderboards = try XCTUnwrap(remoteResult?.get())
71+
let leaderboards = try XCTUnwrap(result.get())
3672

3773
// API Returns 4 leaderboards
3874
XCTAssertEqual(leaderboards.count, 4)
@@ -41,7 +77,7 @@ final class LeaderboardsRemoteV4Tests: XCTestCase {
4177
let topProducts = leaderboards[3]
4278
XCTAssertFalse(topProducts.rows.isEmpty)
4379

44-
// Each prodcut should have non-empty values
80+
// Each product should have non-empty values
4581
let expectedValues = [(quantity: 4, total: 20000.0), (quantity: 1, total: 15.99)]
4682
zip(topProducts.rows, expectedValues).forEach { product, expectedValue in
4783
XCTAssertFalse(product.subject.display.isEmpty)
@@ -53,7 +89,7 @@ final class LeaderboardsRemoteV4Tests: XCTestCase {
5389
}
5490
}
5591

56-
func testLeaderboardsProperlyRelaysNetwokingErrors() {
92+
func testLeaderboardsProperlyRelaysNetworkingErrors() {
5793
// Given
5894
let remote = LeaderboardsRemote(network: network)
5995

Yosemite/Yosemite/Stores/StatsStoreV4.swift

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -147,27 +147,96 @@ public extension StatsStoreV4 {
147147
latestDateToInclude: Date,
148148
quantity: Int,
149149
onCompletion: @escaping (Result<Void, Error>) -> Void) {
150-
let dateFormatter = DateFormatter.Defaults.iso8601WithoutTimeZone
151-
let earliestDate = dateFormatter.string(from: earliestDateToInclude)
152-
let latestDate = dateFormatter.string(from: latestDateToInclude)
153-
leaderboardsRemote.loadLeaderboards(for: siteID,
154-
unit: timeRange.leaderboardsGranularity,
155-
earliestDateToInclude: earliestDate,
156-
latestDateToInclude: latestDate,
157-
quantity: quantity) { [weak self] result in
158-
guard let self = self else { return }
159-
150+
Task { @MainActor in
151+
let result = await loadTopEarnerStats(siteID: siteID,
152+
timeRange: timeRange,
153+
earliestDateToInclude: earliestDateToInclude,
154+
latestDateToInclude: latestDateToInclude,
155+
quantity: quantity)
160156
switch result {
161-
case .success(let leaderboards):
162-
self.convertAndStoreLeaderboardsIntoTopEarners(siteID: siteID,
163-
granularity: timeRange.topEarnerStatsGranularity,
164-
date: latestDateToInclude,
165-
leaderboards: leaderboards,
166-
quantity: quantity,
167-
onCompletion: onCompletion)
168-
157+
case .success:
158+
onCompletion(result)
169159
case .failure(let error):
170-
onCompletion(.failure(error))
160+
if let error = error as? DotcomError, error == .noRestRoute {
161+
let resultFromDeprecatedAPI = await loadTopEarnerStatsWithDeprecatedAPI(siteID: siteID,
162+
timeRange: timeRange,
163+
earliestDateToInclude: earliestDateToInclude,
164+
latestDateToInclude: latestDateToInclude,
165+
quantity: quantity)
166+
onCompletion(resultFromDeprecatedAPI)
167+
} else {
168+
onCompletion(result)
169+
}
170+
}
171+
}
172+
}
173+
174+
@MainActor
175+
func loadTopEarnerStats(siteID: Int64,
176+
timeRange: StatsTimeRangeV4,
177+
earliestDateToInclude: Date,
178+
latestDateToInclude: Date,
179+
quantity: Int) async -> Result<Void, Error> {
180+
await withCheckedContinuation { continuation in
181+
let dateFormatter = DateFormatter.Defaults.iso8601WithoutTimeZone
182+
let earliestDate = dateFormatter.string(from: earliestDateToInclude)
183+
let latestDate = dateFormatter.string(from: latestDateToInclude)
184+
leaderboardsRemote.loadLeaderboards(for: siteID,
185+
unit: timeRange.leaderboardsGranularity,
186+
earliestDateToInclude: earliestDate,
187+
latestDateToInclude: latestDate,
188+
quantity: quantity) { [weak self] result in
189+
guard let self = self else {
190+
return
191+
}
192+
193+
switch result {
194+
case .success(let leaderboards):
195+
self.convertAndStoreLeaderboardsIntoTopEarners(siteID: siteID,
196+
granularity: timeRange.topEarnerStatsGranularity,
197+
date: latestDateToInclude,
198+
leaderboards: leaderboards,
199+
quantity: quantity) { result in
200+
continuation.resume(returning: result)
201+
}
202+
case .failure(let error):
203+
continuation.resume(returning: .failure(error))
204+
}
205+
}
206+
}
207+
}
208+
209+
@MainActor
210+
func loadTopEarnerStatsWithDeprecatedAPI(siteID: Int64,
211+
timeRange: StatsTimeRangeV4,
212+
earliestDateToInclude: Date,
213+
latestDateToInclude: Date,
214+
quantity: Int) async -> Result<Void, Error> {
215+
await withCheckedContinuation { continuation in
216+
let dateFormatter = DateFormatter.Defaults.iso8601WithoutTimeZone
217+
let earliestDate = dateFormatter.string(from: earliestDateToInclude)
218+
let latestDate = dateFormatter.string(from: latestDateToInclude)
219+
leaderboardsRemote.loadLeaderboardsDeprecated(for: siteID,
220+
unit: timeRange.leaderboardsGranularity,
221+
earliestDateToInclude: earliestDate,
222+
latestDateToInclude: latestDate,
223+
quantity: quantity) { [weak self] result in
224+
guard let self = self else {
225+
return
226+
}
227+
228+
switch result {
229+
case .success(let leaderboards):
230+
self.convertAndStoreLeaderboardsIntoTopEarners(siteID: siteID,
231+
granularity: timeRange.topEarnerStatsGranularity,
232+
date: latestDateToInclude,
233+
leaderboards: leaderboards,
234+
quantity: quantity) { result in
235+
continuation.resume(returning: result)
236+
}
237+
case .failure(let error):
238+
continuation.resume(returning: .failure(error))
239+
}
171240
}
172241
}
173242
}

Yosemite/YosemiteTests/Stores/StatsStoreV4Tests.swift

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ final class StatsStoreV4Tests: XCTestCase {
256256
func test_retrieveTopEarnerStats_effectively_persists_retrieved_stats() {
257257
// Given
258258
let store = StatsStoreV4(dispatcher: dispatcher, storageManager: storageManager, network: network)
259-
network.simulateResponse(requestUrlSuffix: "leaderboards", filename: "leaderboards-year")
259+
network.simulateResponse(requestUrlSuffix: "leaderboards/products", filename: "leaderboards-year")
260260
network.simulateResponse(requestUrlSuffix: "products", filename: "leaderboards-products")
261261
XCTAssertEqual(viewStorage.countObjects(ofType: Storage.TopEarnerStats.self), 0)
262262

@@ -289,12 +289,16 @@ final class StatsStoreV4Tests: XCTestCase {
289289

290290
// When
291291
let quantity = 6
292-
let action = StatsActionV4.retrieveTopEarnerStats(siteID: self.sampleSiteID,
293-
timeRange: .thisYear,
294-
earliestDateToInclude: DateFormatter.dateFromString(with: "2020-01-01T00:00:00"),
295-
latestDateToInclude: DateFormatter.dateFromString(with: "2020-07-22T12:00:00"),
296-
quantity: quantity) { _ in }
297-
store.onAction(action)
292+
let _: Void = waitFor { promise in
293+
let action = StatsActionV4.retrieveTopEarnerStats(siteID: self.sampleSiteID,
294+
timeRange: .thisYear,
295+
earliestDateToInclude: DateFormatter.dateFromString(with: "2020-01-01T00:00:00"),
296+
latestDateToInclude: DateFormatter.dateFromString(with: "2020-07-22T12:00:00"),
297+
quantity: quantity) { _ in
298+
promise(())
299+
}
300+
store.onAction(action)
301+
}
298302

299303
// Then
300304
let expectedQuantityParam = "per_page=\(quantity)"
@@ -306,7 +310,7 @@ final class StatsStoreV4Tests: XCTestCase {
306310
func test_retrieveTopEarnerStats_effectively_persists_updated_items() {
307311
// Given
308312
let store = StatsStoreV4(dispatcher: dispatcher, storageManager: storageManager, network: network)
309-
network.simulateResponse(requestUrlSuffix: "leaderboards", filename: "leaderboards-year-alt")
313+
network.simulateResponse(requestUrlSuffix: "leaderboards/products", filename: "leaderboards-year-alt")
310314
network.simulateResponse(requestUrlSuffix: "products", filename: "leaderboards-products")
311315
store.upsertStoredTopEarnerStats(readOnlyStats: sampleTopEarnerStats())
312316

@@ -331,6 +335,35 @@ final class StatsStoreV4Tests: XCTestCase {
331335
XCTAssertEqual(readOnlyTopEarnerStats, sampleTopEarnerStatsMutated())
332336
}
333337

338+
func test_retrieveTopEarnerStats_calls_deprecated_leaderboards_api_and_persits_stats_on_leaderboards_restnoroute_error() {
339+
// Given
340+
let store = StatsStoreV4(dispatcher: dispatcher, storageManager: storageManager, network: network)
341+
network.simulateError(requestUrlSuffix: "leaderboards/products", error: DotcomError.noRestRoute)
342+
network.simulateResponse(requestUrlSuffix: "leaderboards", filename: "leaderboards-year")
343+
network.simulateResponse(requestUrlSuffix: "products", filename: "leaderboards-products")
344+
XCTAssertEqual(viewStorage.countObjects(ofType: Storage.TopEarnerStats.self), 0)
345+
346+
// When
347+
let result: Result<Void, Error> = waitFor { promise in
348+
let action = StatsActionV4.retrieveTopEarnerStats(siteID: self.sampleSiteID,
349+
timeRange: .thisYear,
350+
earliestDateToInclude: DateFormatter.dateFromString(with: "2020-01-01T00:00:00"),
351+
latestDateToInclude: DateFormatter.dateFromString(with: "2020-07-22T12:00:00"),
352+
quantity: 3) { result in
353+
promise(result)
354+
}
355+
store.onAction(action)
356+
}
357+
358+
// Then
359+
XCTAssertTrue(result.isSuccess)
360+
XCTAssertEqual(viewStorage.countObjects(ofType: Storage.TopEarnerStats.self), 1)
361+
XCTAssertEqual(viewStorage.countObjects(ofType: Storage.TopEarnerStatsItem.self), 2)
362+
363+
let readOnlyTopEarnerStats = viewStorage.firstObject(ofType: Storage.TopEarnerStats.self)?.toReadOnly()
364+
XCTAssertEqual(readOnlyTopEarnerStats, sampleTopEarnerStats())
365+
}
366+
334367
/// Verifies that `StatsActionV4.retrieveTopEarnerStats` returns an error whenever there is an error response from the backend.
335368
///
336369
func test_retrieveTopEarnerStats_returns_error_upon_response_error() {

0 commit comments

Comments
 (0)