Skip to content

Commit 00d7add

Browse files
committed
Merge branch 'trunk' into fix/explat-include-anonid-in-loggedin-requests
2 parents e31aa3b + 681abc0 commit 00d7add

File tree

16 files changed

+532
-67
lines changed

16 files changed

+532
-67
lines changed

.rubocop.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
AllCops:
2+
Exclude:
3+
- DerivedData/**/*
4+
- Pods/**/*
5+
- vendor/**/*
6+
NewCops: enable
7+
18
Metrics/BlockLength:
29
Exclude:
310
- fastlane/Fastfile
@@ -12,6 +19,7 @@ Style/AsciiComments:
1219
Layout/LineLength:
1320
Exclude:
1421
- fastlane/Fastfile
22+
- Podfile
1523

1624
Naming/FileName:
1725
Exclude:

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

Podfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ post_install do |installer|
332332

333333
# Flag Alpha builds for Tracks
334334
# ============================
335+
# rubocop:disable Style/CombinableLoops
335336
installer.pods_project.targets.each do |target|
336337
next unless target.name == 'Automattic-Tracks-iOS'
337338

@@ -341,4 +342,5 @@ post_install do |installer|
341342
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)', 'ALPHA=1']
342343
end
343344
end
345+
# rubocop:enable Style/CombinableLoops
344346
end

WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,8 @@ extension WooAnalyticsEvent {
370370
static let orderID = "id"
371371
static let hasMultipleShippingLines = "has_multiple_shipping_lines"
372372
static let hasMultipleFeeLines = "has_multiple_fee_lines"
373+
static let errorMessage = "error_message"
374+
static let validationScenario = "validation_scenario"
373375
}
374376

375377
static func orderOpen(order: Order) -> WooAnalyticsEvent {
@@ -488,6 +490,25 @@ extension WooAnalyticsEvent {
488490
static func pluginsNotSyncedYet() -> WooAnalyticsEvent {
489491
WooAnalyticsEvent(statName: .pluginsNotSyncedYet, properties: [:])
490492
}
493+
494+
/// Tracked when the Order details view detects a malformed shipping address data.
495+
///
496+
static func addressValidationFailed(error: AddressValidator.AddressValidationError, orderID: Int64) -> WooAnalyticsEvent {
497+
switch error {
498+
case .local(let errorMessage):
499+
return WooAnalyticsEvent(statName: .orderAddressValidationError, properties: [
500+
Keys.errorMessage: errorMessage,
501+
Keys.validationScenario: "local",
502+
Keys.orderID: orderID
503+
])
504+
case .remote(let error):
505+
return WooAnalyticsEvent(statName: .orderAddressValidationError, properties: [
506+
Keys.errorMessage: "\(String(describing: error?.addressError ?? "Unknown"))",
507+
Keys.validationScenario: "remote",
508+
Keys.orderID: orderID
509+
])
510+
}
511+
}
491512
}
492513
}
493514

WooCommerce/Classes/Analytics/WooAnalyticsStat.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ public enum WooAnalyticsStat: String {
269269
case collectPaymentTapped = "payments_flow_order_collect_payment_tapped"
270270
case orderViewCustomFieldsTapped = "order_view_custom_fields_tapped"
271271
case orderDetailWaitingTimeLoaded = "order_detail_waiting_time_loaded"
272+
case orderAddressValidationError = "order_address_validation_error"
272273

273274
// MARK: Order List Sorting/Filtering
274275
//

WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ final class OrderDetailsViewModel {
1515
private let stores: StoresManager
1616
private let storageManager: StorageManagerType
1717
private let currencyFormatter: CurrencyFormatter
18+
private let addressValidator: AddressValidator
1819

1920
private(set) var order: Order
2021

@@ -34,6 +35,7 @@ final class OrderDetailsViewModel {
3435
self.stores = stores
3536
self.storageManager = storageManager
3637
self.currencyFormatter = currencyFormatter
38+
addressValidator = AddressValidator(siteID: order.siteID, stores: stores)
3739
}
3840

3941
func update(order newOrder: Order) {
@@ -544,6 +546,11 @@ extension OrderDetailsViewModel {
544546
func syncShippingLabels(onCompletion: ((Error?) -> ())? = nil) {
545547
// If the plugin is not active, there is no point on continuing with a request that will fail.
546548
isPluginActive(SitePlugin.SupportedPlugin.WCShip) { [weak self] isActive in
549+
// We're looking to measure how often an order contains an invalid address
550+
// and validate it through the Shipping label plugin, so we need to trigger
551+
// this call when it's possible to verify the plugin presence
552+
self?.startAddressValidation(shippingLabelPluginIsActive: isActive)
553+
547554
guard let self = self, isActive else {
548555
onCompletion?(nil)
549556
return
@@ -569,6 +576,20 @@ extension OrderDetailsViewModel {
569576
}
570577
}
571578

579+
func startAddressValidation(shippingLabelPluginIsActive: Bool) {
580+
guard let orderShippingAddress = order.shippingAddress, orderShippingAddress != Address.empty else {
581+
return
582+
}
583+
584+
let orderID = order.orderID
585+
586+
addressValidator.validate(address: orderShippingAddress, onlyLocally: !shippingLabelPluginIsActive) { result in
587+
if let error = result.failure {
588+
ServiceLocator.analytics.track(event: WooAnalyticsEvent.Orders.addressValidationFailed(error: error, orderID: orderID))
589+
}
590+
}
591+
}
592+
572593
func syncSavedReceipts(onCompletion: ((Error?) -> ())? = nil) {
573594
let action = ReceiptAction.loadReceipt(order: order) { [weak self] result in
574595
switch result {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import Yosemite
2+
3+
/// Reusable implementation of the Address validation introduced by the
4+
/// `ShippingLabelAddressFormViewModel`, but striped of the specifics to serve the form UI
5+
///
6+
class AddressValidator {
7+
let siteID: Int64
8+
private let stores: StoresManager
9+
10+
init(siteID: Int64, stores: StoresManager) {
11+
self.siteID = siteID
12+
self.stores = stores
13+
}
14+
15+
func validate(address: Address, onlyLocally: Bool, onCompletion: @escaping (Result<Void, AddressValidationError>) -> Void) {
16+
let convertedAddress = ShippingLabelAddress(company: address.company ?? "",
17+
name: address.fullName,
18+
phone: address.phone ?? "",
19+
country: address.country,
20+
state: address.state,
21+
address1: address.address1 ,
22+
address2: address.address2 ?? "",
23+
city: address.city,
24+
postcode: address.postcode)
25+
26+
let localErrors = validateAddressLocally(addressToBeValidated: convertedAddress)
27+
if localErrors.isNotEmpty {
28+
let localErrorMessage = localErrors.map { $0.rawValue }.joined(separator: ", ")
29+
onCompletion(.failure(.local(localErrorMessage)))
30+
return
31+
}
32+
33+
if onlyLocally {
34+
onCompletion(.success(()))
35+
return
36+
}
37+
38+
let addressToBeVerified = ShippingLabelAddressVerification(address: convertedAddress, type: .destination)
39+
let action = ShippingLabelAction.validateAddress(siteID: siteID, address: addressToBeVerified) { result in
40+
switch result {
41+
case .success:
42+
onCompletion(.success(()))
43+
case .failure(let error):
44+
onCompletion(.failure(.remote(error as? ShippingLabelAddressValidationError)))
45+
}
46+
}
47+
stores.dispatch(action)
48+
}
49+
50+
private func validateAddressLocally(addressToBeValidated: ShippingLabelAddress) -> [LocalValidationError] {
51+
var errors: [LocalValidationError] = []
52+
53+
if addressToBeValidated.name.isEmpty && addressToBeValidated.company.isEmpty {
54+
errors.append(.name)
55+
}
56+
if addressToBeValidated.address1.isEmpty {
57+
errors.append(.address)
58+
}
59+
if addressToBeValidated.city.isEmpty {
60+
errors.append(.city)
61+
}
62+
if addressToBeValidated.postcode.isEmpty {
63+
errors.append(.postcode)
64+
}
65+
if addressToBeValidated.state.isEmpty {
66+
errors.append(.state)
67+
}
68+
if addressToBeValidated.country.isEmpty {
69+
errors.append(.country)
70+
}
71+
72+
return errors
73+
}
74+
75+
enum LocalValidationError: String {
76+
case name = "Customer name and company are empty"
77+
case address = "Address is empty"
78+
case city = "City is empty"
79+
case postcode = "Postcode is empty"
80+
case state = "State is empty"
81+
case country = "Country is empty"
82+
}
83+
84+
enum AddressValidationError: Error {
85+
case local(String)
86+
case remote(ShippingLabelAddressValidationError?)
87+
}
88+
}

0 commit comments

Comments
 (0)