diff --git a/Fakes/Fakes/Networking.generated.swift b/Fakes/Fakes/Networking.generated.swift index 92703032655..3270fa4e817 100644 --- a/Fakes/Fakes/Networking.generated.swift +++ b/Fakes/Fakes/Networking.generated.swift @@ -186,6 +186,7 @@ extension Customer { /// public static func fake() -> Customer { .init( + siteID: .fake(), customerID: .fake(), email: .fake(), firstName: .fake(), @@ -1694,6 +1695,17 @@ extension User { ) } } +extension WCAnalyticsCustomer { + /// Returns a "ready to use" type filled with fake values. + /// + public static func fake() -> WCAnalyticsCustomer { + .init( + siteID: .fake(), + userID: .fake(), + name: .fake() + ) + } +} extension WCPayAccountStatusEnum { /// Returns a "ready to use" type filled with fake values. /// diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index 77900c0c4a5..9c006f764f3 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -324,6 +324,7 @@ 57BE08D82409B63800F6DCED /* reviews-missing-avatar-urls.json in Resources */ = {isa = PBXBuildFile; fileRef = 57BE08D72409B63700F6DCED /* reviews-missing-avatar-urls.json */; }; 57E8FED3246616AC0057CD68 /* Result+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57E8FED2246616AC0057CD68 /* Result+Extensions.swift */; }; 6647C0161DAC6AB6570C53A7 /* Pods_Networking.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F3F25DC15EC1D7C631169CB5 /* Pods_Networking.framework */; }; + 688908AE28FF920C0081A07E /* customer-2.json in Resources */ = {isa = PBXBuildFile; fileRef = 688908AD28FF920C0081A07E /* customer-2.json */; }; 68BD37B328D9B8BD00C2A517 /* CustomerRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68BD37B228D9B8BD00C2A517 /* CustomerRemoteTests.swift */; }; 68C87B342862D40E00A99054 /* setting-all-except-countries.json in Resources */ = {isa = PBXBuildFile; fileRef = 68C87B332862D40E00A99054 /* setting-all-except-countries.json */; }; 68CB800C28D87BC800E169F8 /* Customer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68CB800B28D87BC800E169F8 /* Customer.swift */; }; @@ -1033,6 +1034,7 @@ 5726F7332460A8F00031CAAC /* CopiableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopiableTests.swift; sourceTree = ""; }; 57BE08D72409B63700F6DCED /* reviews-missing-avatar-urls.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "reviews-missing-avatar-urls.json"; sourceTree = ""; }; 57E8FED2246616AC0057CD68 /* Result+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+Extensions.swift"; sourceTree = ""; }; + 688908AD28FF920C0081A07E /* customer-2.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "customer-2.json"; sourceTree = ""; }; 68BD37B228D9B8BD00C2A517 /* CustomerRemoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerRemoteTests.swift; sourceTree = ""; }; 68C87B332862D40E00A99054 /* setting-all-except-countries.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "setting-all-except-countries.json"; sourceTree = ""; }; 68CB800B28D87BC800E169F8 /* Customer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Customer.swift; sourceTree = ""; }; @@ -2147,6 +2149,7 @@ 68C87B332862D40E00A99054 /* setting-all-except-countries.json */, 68CB801528D8A39700E169F8 /* customer.json */, 68F48B1228E3E5750045C15B /* wc-analytics-customers.json */, + 688908AD28FF920C0081A07E /* customer-2.json */, ); path = Responses; sourceTree = ""; @@ -2580,6 +2583,7 @@ D823D91422377EE600C90817 /* shipment_tracking_providers.json in Resources */, 4599FC5C24A6276F0056157A /* product-tags-all.json in Resources */, 03DCB77E262738E300C8953D /* coupon.json in Resources */, + 688908AE28FF920C0081A07E /* customer-2.json in Resources */, 034480C327A42F9100DFACD2 /* order-with-charge.json in Resources */, 74A7B4BE217A841400E85A8B /* broken-settings-general.json in Resources */, 026CF624237D839B009563D4 /* product-variations-load-all.json in Resources */, diff --git a/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift b/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift index a3d49214916..8d0c68c087c 100644 --- a/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift +++ b/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift @@ -171,6 +171,7 @@ extension CouponReport { extension Customer { public func copy( + siteID: CopiableProp = .copy, customerID: CopiableProp = .copy, email: CopiableProp = .copy, firstName: NullableCopiableProp = .copy, @@ -178,6 +179,7 @@ extension Customer { billing: NullableCopiableProp
= .copy, shipping: NullableCopiableProp
= .copy ) -> Customer { + let siteID = siteID ?? self.siteID let customerID = customerID ?? self.customerID let email = email ?? self.email let firstName = firstName ?? self.firstName @@ -186,6 +188,7 @@ extension Customer { let shipping = shipping ?? self.shipping return Customer( + siteID: siteID, customerID: customerID, email: email, firstName: firstName, @@ -1948,6 +1951,24 @@ extension TopEarnerStatsItem { } } +extension WCAnalyticsCustomer { + public func copy( + siteID: CopiableProp = .copy, + userID: CopiableProp = .copy, + name: NullableCopiableProp = .copy + ) -> WCAnalyticsCustomer { + let siteID = siteID ?? self.siteID + let userID = userID ?? self.userID + let name = name ?? self.name + + return WCAnalyticsCustomer( + siteID: siteID, + userID: userID, + name: name + ) + } +} + extension WCPayCardPaymentDetails { public func copy( brand: CopiableProp = .copy, diff --git a/Networking/Networking/Model/Customer.swift b/Networking/Networking/Model/Customer.swift index 70d57332fe8..0e8db6e7cb8 100644 --- a/Networking/Networking/Model/Customer.swift +++ b/Networking/Networking/Model/Customer.swift @@ -5,6 +5,8 @@ import Codegen /// https://woocommerce.github.io/woocommerce-rest-api-docs/#customer-properties /// public struct Customer: Codable, GeneratedCopiable, GeneratedFakeable { + /// The siteID for the customer + public let siteID: Int64 /// Unique identifier for the customer public let customerID: Int64 @@ -26,12 +28,14 @@ public struct Customer: Codable, GeneratedCopiable, GeneratedFakeable { /// Customer struct initializer /// - public init(customerID: Int64, + public init(siteID: Int64, + customerID: Int64, email: String, firstName: String?, lastName: String?, billing: Address?, shipping: Address?) { + self.siteID = siteID self.customerID = customerID self.email = email self.firstName = firstName @@ -43,6 +47,10 @@ public struct Customer: Codable, GeneratedCopiable, GeneratedFakeable { /// Public initializer for the Customer /// public init(from decoder: Decoder) throws { + guard let siteID = decoder.userInfo[.siteID] as? Int64 else { + throw CustomerDecodingError.missingSiteID + } + let container = try decoder.container(keyedBy: CodingKeys.self) let customerID = try container.decode(Int64.self, forKey: .customerID) @@ -52,7 +60,8 @@ public struct Customer: Codable, GeneratedCopiable, GeneratedFakeable { let billing = try? container.decode(Address.self, forKey: .billing) let shipping = try? container.decode(Address.self, forKey: .shipping) - self.init(customerID: customerID, + self.init(siteID: siteID, + customerID: customerID, email: email, firstName: firstName, lastName: lastName, @@ -73,4 +82,8 @@ extension Customer { case billing case shipping } + + enum CustomerDecodingError: Error { + case missingSiteID + } } diff --git a/Networking/Networking/Model/WCAnalyticsCustomer.swift b/Networking/Networking/Model/WCAnalyticsCustomer.swift index 641167f02d9..d8c5c2c15c3 100644 --- a/Networking/Networking/Model/WCAnalyticsCustomer.swift +++ b/Networking/Networking/Model/WCAnalyticsCustomer.swift @@ -1,6 +1,9 @@ import Foundation +import Codegen -public struct WCAnalyticsCustomer: Codable { +public struct WCAnalyticsCustomer: Codable, GeneratedCopiable, GeneratedFakeable { + /// The siteID for the WCAnalyticsCustomer + public let siteID: Int64 /// Unique identifier for the user public let userID: Int64 @@ -10,7 +13,8 @@ public struct WCAnalyticsCustomer: Codable { /// WCAnalyticsCustomer struct Initializer /// - public init(userID: Int64, name: String?) { + public init(siteID: Int64, userID: Int64, name: String?) { + self.siteID = siteID self.userID = userID self.name = name } @@ -18,12 +22,16 @@ public struct WCAnalyticsCustomer: Codable { /// Public initializer for WCAnalyticsCustomer /// public init(from decoder: Decoder) throws { + guard let siteID = decoder.userInfo[.siteID] as? Int64 else { + throw DecodingError.missingSiteID + } + let container = try decoder.container(keyedBy: CodingKeys.self) let userID = try container.decode(Int64.self, forKey: .userID) let name = try container.decode(String.self, forKey: .name) - self.init(userID: userID, name: name) + self.init(siteID: siteID, userID: userID, name: name) } } @@ -32,4 +40,8 @@ extension WCAnalyticsCustomer { case userID = "user_id" case name = "name" } + + enum DecodingError: Error { + case missingSiteID + } } diff --git a/Networking/NetworkingTests/Responses/customer-2.json b/Networking/NetworkingTests/Responses/customer-2.json new file mode 100644 index 00000000000..377e18e7911 --- /dev/null +++ b/Networking/NetworkingTests/Responses/customer-2.json @@ -0,0 +1,55 @@ +{ + "data": { + "id": 26, + "date_created": "2017-03-21T16:09:28", + "date_created_gmt": "2017-03-21T19:09:28", + "date_modified": "2017-03-21T16:09:30", + "date_modified_gmt": "2017-03-21T19:09:30", + "email": "john.doe@example.com", + "first_name": "Johnny", + "last_name": "Doe", + "role": "customer", + "username": "johnny.doe", + "billing": { + "first_name": "John", + "last_name": "Doe", + "company": "", + "address_1": "969 Market", + "address_2": "", + "city": "San Francisco", + "state": "CA", + "postcode": "94103", + "country": "US", + "email": "john.doe@example.com", + "phone": "(555) 555-5555" + }, + "shipping": { + "first_name": "John", + "last_name": "Doe", + "company": "", + "address_1": "969 Market", + "address_2": "", + "city": "San Francisco", + "state": "CA", + "postcode": "94103", + "country": "US" + }, + "is_paying_customer": false, + "avatar_url": "https://secure.gravatar.com/avatar/8eb1b522f60d11fa897de1dc6351b7e8?s=96", + "meta_data": [], + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/customers/25" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/customers" + } + ] + } + } +} + + diff --git a/Storage/Storage/Tools/StorageType+Extensions.swift b/Storage/Storage/Tools/StorageType+Extensions.swift index 4d294d0c959..e8925f18845 100644 --- a/Storage/Storage/Tools/StorageType+Extensions.swift +++ b/Storage/Storage/Tools/StorageType+Extensions.swift @@ -599,6 +599,21 @@ public extension StorageType { return firstObject(ofType: WCPayCharge.self, matching: predicate) } + // MARK: - Customers + + /// Returns a single Customer given a `siteID` and `customerID` + /// + func loadCustomer(siteID: Int64, customerID: Int64) -> Customer? { + let predicate = \Customer.siteID == siteID && \Customer.customerID == customerID + return firstObject(ofType: Customer.self, matching: predicate) + } + /// Returns a CustomerSearchResult given a `siteID` and a `keyword` + /// + func loadCustomerSearchResult(siteID: Int64, keyword: String) -> CustomerSearchResult? { + let predicate = \CustomerSearchResult.siteID == siteID && \CustomerSearchResult.keyword == keyword + return firstObject(ofType: CustomerSearchResult.self, matching: predicate) + } + // MARK: - System plugins /// Returns all stored system plugins for a provided `siteID`. diff --git a/Storage/StorageTests/Tools/StorageTypeExtensionsTests.swift b/Storage/StorageTests/Tools/StorageTypeExtensionsTests.swift index 68ba90cb0a4..801310e8e2d 100644 --- a/Storage/StorageTests/Tools/StorageTypeExtensionsTests.swift +++ b/Storage/StorageTests/Tools/StorageTypeExtensionsTests.swift @@ -147,6 +147,34 @@ final class StorageTypeExtensionsTests: XCTestCase { XCTAssertEqual(coupon, storedCoupon) } + func test_loadCustomer_by_siteID_and_customerID() throws { + // Given + let customerID: Int64 = 123 + let customer = storage.insertNewObject(ofType: Customer.self) + customer.siteID = sampleSiteID + customer.customerID = customerID + + // When + let storedCustomer = try XCTUnwrap(storage.loadCustomer(siteID: sampleSiteID, customerID: customerID)) + + // Then + XCTAssertEqual(customer, storedCustomer) + } + + func test_loadCustomerSearchResult_by_siteID_and_keyword() throws { + // Given + let keyword: String = "some keyword" + let customerSearchResult = storage.insertNewObject(ofType: CustomerSearchResult.self) + customerSearchResult.siteID = sampleSiteID + customerSearchResult.keyword = keyword + + // When + let storedCustomerSearchResult = try XCTUnwrap(storage.loadCustomerSearchResult(siteID: sampleSiteID, keyword: keyword )) + + // Then + XCTAssertEqual(customerSearchResult, storedCustomerSearchResult) + } + func test_loadOrderFeeLine_by_siteID_feeID() throws { // Given let feeID: Int64 = 123 diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CreateOrderAddressFormViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CreateOrderAddressFormViewModel.swift index b4c19f20d6c..8fbc9b475ce 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CreateOrderAddressFormViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CreateOrderAddressFormViewModel.swift @@ -107,8 +107,19 @@ final class CreateOrderAddressFormViewModel: AddressFormViewModel, AddressFormVi siteID: siteID, keyword: "hello") { result in switch result { - case .success(_): - print("Success!") + case .success(let customers): + let storage = ServiceLocator.storageManager + guard let result = storage.viewStorage.loadCustomerSearchResult(siteID: self.siteID, keyword: "hello") else { + return + } + print("Site ID: \(result.siteID), keyword: \(result.keyword), Customers: \(result.customers?.count as Any)") + for eachCustomer in customers { + let output = """ + Customer: \(eachCustomer.customerID), + Name: \(String(describing: eachCustomer.firstName)) \(String(describing: eachCustomer.lastName)) + """ + print(output) + } case .failure(let error): print(error) } diff --git a/Yosemite/Yosemite.xcodeproj/project.pbxproj b/Yosemite/Yosemite.xcodeproj/project.pbxproj index 5e236411250..c1b3c94c187 100644 --- a/Yosemite/Yosemite.xcodeproj/project.pbxproj +++ b/Yosemite/Yosemite.xcodeproj/project.pbxproj @@ -213,6 +213,7 @@ 578CE7972475FD8200492EBF /* MockProductReview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 578CE7962475FD8200492EBF /* MockProductReview.swift */; }; 57DFCC7925003C4000251E0C /* FetchResultSnapshotsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DFCC7825003C4000251E0C /* FetchResultSnapshotsProvider.swift */; }; 681D952B28E0F62B00C4039E /* CustomerAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 681D952A28E0F62B00C4039E /* CustomerAction.swift */; }; + 6889089F28F7B8540081A07E /* Customer+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6889089E28F7B8540081A07E /* Customer+ReadOnlyConvertible.swift */; }; 689D11D52891B9A400F6A83F /* WooFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 689D11D42891B9A400F6A83F /* WooFoundation.framework */; }; 68BD37B528DB2E9800C2A517 /* CustomerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68BD37B428DB2E9800C2A517 /* CustomerStore.swift */; }; 68BD37B928DB323D00C2A517 /* CustomerStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68BD37B828DB323D00C2A517 /* CustomerStoreTests.swift */; }; @@ -617,6 +618,7 @@ 57DFCC7825003C4000251E0C /* FetchResultSnapshotsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchResultSnapshotsProvider.swift; sourceTree = ""; }; 585B973F61632665297738A3 /* Pods-Yosemite.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Yosemite.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-Yosemite/Pods-Yosemite.release-alpha.xcconfig"; sourceTree = ""; }; 681D952A28E0F62B00C4039E /* CustomerAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerAction.swift; sourceTree = ""; }; + 6889089E28F7B8540081A07E /* Customer+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Customer+ReadOnlyConvertible.swift"; sourceTree = ""; }; 689D11D42891B9A400F6A83F /* WooFoundation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = WooFoundation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 68BD37B428DB2E9800C2A517 /* CustomerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerStore.swift; sourceTree = ""; }; 68BD37B828DB323D00C2A517 /* CustomerStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerStoreTests.swift; sourceTree = ""; }; @@ -1270,6 +1272,7 @@ 031C1EAB27B1873200298699 /* WCPayCardPresentReceiptDetails+ReadOnlyConvertible.swift */, 031C1EAF27B1879C00298699 /* WCPayCardPaymentDetails+ReadOnlyConvertible.swift */, 031C1EAD27B1877000298699 /* WCPayCardPresentPaymentDetails+ReadOnlyConvertible.swift */, + 6889089E28F7B8540081A07E /* Customer+ReadOnlyConvertible.swift */, ); path = Storage; sourceTree = ""; @@ -2106,6 +2109,7 @@ 7499936420EFBC1B00CF01CD /* OrderNoteAction.swift in Sources */, 7455D4692141B59E00FA8C1F /* TopEarnerStatsItem+ReadOnlyConvertible.swift in Sources */, D8C11A5222DF2DA200D4A88D /* StatsActionV4.swift in Sources */, + 6889089F28F7B8540081A07E /* Customer+ReadOnlyConvertible.swift in Sources */, 68BD37B528DB2E9800C2A517 /* CustomerStore.swift in Sources */, CE179D55235F4E1700C24EB3 /* RefundAction.swift in Sources */, CE5F9A7A22B2D455001755E8 /* Array+Helpers.swift in Sources */, diff --git a/Yosemite/Yosemite/Model/Model.swift b/Yosemite/Yosemite/Model/Model.swift index ddf2da90f79..905a6f920fe 100644 --- a/Yosemite/Yosemite/Model/Model.swift +++ b/Yosemite/Yosemite/Model/Model.swift @@ -165,6 +165,8 @@ public typealias StorageAccount = Storage.Account public typealias StorageAccountSettings = Storage.AccountSettings public typealias StorageAttribute = Storage.GenericAttribute public typealias StorageCoupon = Storage.Coupon +public typealias StorageCustomer = Storage.Customer +public typealias StorageCustomerSearchResult = Storage.CustomerSearchResult public typealias StorageCouponSearchResult = Storage.CouponSearchResult public typealias StorageAddOnGroup = Storage.AddOnGroup public typealias StorageAnnouncement = Storage.Announcement diff --git a/Yosemite/Yosemite/Model/Storage/Customer+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/Customer+ReadOnlyConvertible.swift new file mode 100644 index 00000000000..f0a5dd7fbe4 --- /dev/null +++ b/Yosemite/Yosemite/Model/Storage/Customer+ReadOnlyConvertible.swift @@ -0,0 +1,93 @@ +import Foundation +import Storage + +// MARK: - Storage.Customer: ReadOnlyConvertible +// +extension Storage.Customer: ReadOnlyConvertible { + /// Updates the `Storage.Customer` from the ReadOnly representation (`Networking.Customer`) + /// + public func update(with customer: Yosemite.Customer) { + customerID = customer.customerID + siteID = customer.siteID + email = customer.email + firstName = customer.firstName + lastName = customer.lastName + + billingFirstName = customer.billing?.firstName + billingLastName = customer.billing?.lastName + billingCompany = customer.billing?.company + billingAddress1 = customer.billing?.address1 + billingAddress2 = customer.billing?.address2 + billingCity = customer.billing?.city + billingState = customer.billing?.state + billingPostcode = customer.billing?.postcode + billingCountry = customer.billing?.country + billingPhone = customer.billing?.phone + billingEmail = customer.billing?.email + + shippingFirstName = customer.shipping?.firstName + shippingLastName = customer.shipping?.lastName + shippingCompany = customer.shipping?.company + shippingAddress1 = customer.shipping?.address1 + shippingAddress2 = customer.shipping?.address2 + shippingCity = customer.shipping?.city + shippingState = customer.shipping?.state + shippingPostcode = customer.shipping?.postcode + shippingCountry = customer.shipping?.country + shippingPhone = customer.shipping?.phone + shippingEmail = customer.shipping?.email + } + + /// Returns a ReadOnly (`Networking.Customer`) version of the `Storage.Customer` + /// + public func toReadOnly() -> Yosemite.Customer { + return Customer( + siteID: siteID, + customerID: customerID, + email: email ?? "", + firstName: firstName ?? "", + lastName: lastName ?? "", + billing: createReadOnlyBillingAddress(), + shipping: createReadOnlyShippingAddress() + ) + } + + /// Helpers + private func createReadOnlyBillingAddress() -> Yosemite.Address? { + guard let billingCountry = billingCountry else { + return nil + } + + return Address(firstName: billingFirstName ?? "", + lastName: billingLastName ?? "", + company: billingCompany ?? "", + address1: billingAddress1 ?? "", + address2: billingAddress2 ?? "", + city: billingCity ?? "", + state: billingState ?? "", + postcode: billingPostcode ?? "", + country: billingCountry, + phone: billingPhone, + email: billingEmail + ) + } + + private func createReadOnlyShippingAddress() -> Yosemite.Address? { + guard let shippingCountry = shippingCountry else { + return nil + } + + return Address(firstName: shippingFirstName ?? "", + lastName: shippingLastName ?? "", + company: shippingCompany ?? "", + address1: shippingAddress1 ?? "", + address2: shippingAddress2 ?? "", + city: shippingCity ?? "", + state: shippingState ?? "", + postcode: shippingPostcode ?? "", + country: shippingCountry, + phone: shippingPhone, + email: shippingEmail + ) + } +} diff --git a/Yosemite/Yosemite/Stores/CustomerStore.swift b/Yosemite/Yosemite/Stores/CustomerStore.swift index db62641f4bb..1cd6bdd6be9 100644 --- a/Yosemite/Yosemite/Stores/CustomerStore.swift +++ b/Yosemite/Yosemite/Stores/CustomerStore.swift @@ -6,6 +6,9 @@ public final class CustomerStore: Store { private let customerRemote: CustomerRemote private let searchRemote: WCAnalyticsCustomerRemote + private lazy var sharedDerivedStorage: StorageType = { + return storageManager.writerDerivedStorage + }() init(dispatcher: Dispatcher, storageManager: StorageManagerType, @@ -69,7 +72,7 @@ public final class CustomerStore: Store { guard let self else { return } switch result { case .success(let customers): - self.mapSearchResultsToCustomerObjects(for: siteID, with: customers, onCompletion: onCompletion) + self.mapSearchResultsToCustomerObjects(for: siteID, with: keyword, with: customers, onCompletion: onCompletion) case .failure(let error): onCompletion(.failure(error)) } @@ -92,8 +95,9 @@ public final class CustomerStore: Store { guard let self else { return } switch result { case .success(let customer): - self.upsertCustomer(siteID: siteID, readOnlyCustomer: customer, onCompletion: {}) - onCompletion(.success(customer)) + self.upsertCustomer(siteID: siteID, readOnlyCustomer: customer, in: self.sharedDerivedStorage, onCompletion: { + onCompletion(.success(customer)) + }) case .failure(let error): onCompletion(.failure(error)) } @@ -104,42 +108,85 @@ public final class CustomerStore: Store { /// /// - Parameters: /// - siteID: The site for which customers should be fetched. + /// - keyword: The keyword used for the Customer search query. /// - searchResults: A WCAnalyticsCustomer collection that represents the matches we've got from the API based in our keyword search. /// - onCompletion: Invoked when the operation finishes. Will map the result to a `[Customer]` entity. /// private func mapSearchResultsToCustomerObjects(for siteID: Int64, + with keyword: String, with searchResults: [WCAnalyticsCustomer], onCompletion: @escaping (Result<[Customer], Error>) -> Void) { - var results = [Customer]() + var customers = [Customer]() let group = DispatchGroup() for result in searchResults { group.enter() self.retrieveCustomer(for: siteID, with: result.userID, onCompletion: { result in if let customer = try? result.get() { - results.append(customer) + customers.append(customer) } group.leave() }) } group.notify(queue: .main) { - self.upsertSearchCustomerResults(siteID: siteID, readOnlySearchResults: searchResults, onCompletion: {}) - onCompletion(.success(results)) + self.upsertSearchCustomerResult( + siteID: siteID, + keyword: keyword, + readOnlyCustomers: customers, + onCompletion: { + onCompletion(.success(customers)) + } + ) } } +} +// MARK: Storage operations +private extension CustomerStore { /// Inserts or updates CustomerSearchResults in Storage /// - private func upsertSearchCustomerResults(siteID: Int64, readOnlySearchResults: [Networking.WCAnalyticsCustomer], onCompletion: @escaping () -> Void) { - for _ in readOnlySearchResults { - // Logic for inserting or updating in Storage will go here. Not implemented yet. - // https://github.com/woocommerce/woocommerce-ios/issues/7741 + private func upsertSearchCustomerResult(siteID: Int64, + keyword: String, + readOnlyCustomers: [Networking.Customer], + onCompletion: @escaping () -> Void) { + sharedDerivedStorage.perform { [weak self] in + guard let self = self else { return } + let storedSearchResult = self.sharedDerivedStorage.loadCustomerSearchResult(siteID: siteID, keyword: keyword) ?? + self.sharedDerivedStorage.insertNewObject(ofType: Storage.CustomerSearchResult.self) + + storedSearchResult.siteID = siteID + storedSearchResult.keyword = keyword + + for result in readOnlyCustomers { + if let storedCustomer = self.sharedDerivedStorage.loadCustomer(siteID: siteID, customerID: result.customerID) { + storedSearchResult.addToCustomers(storedCustomer) + } + } + } + storageManager.saveDerivedType(derivedStorage: self.sharedDerivedStorage) { + DispatchQueue.main.async(execute: onCompletion) } } - /// Inserts or updates Customers in Storage + + /// Inserts or updates Customer entities into Storage /// - private func upsertCustomer(siteID: Int64, readOnlyCustomer: Networking.Customer, onCompletion: @escaping () -> Void) { - // Logic for inserting or updating in Storage will go here. Not implemented yet. - // https://github.com/woocommerce/woocommerce-ios/issues/7741 + private func upsertCustomer(siteID: Int64, readOnlyCustomer: Networking.Customer, in storage: StorageType, onCompletion: @escaping () -> Void) { + + storage.perform { + let storageCustomer: Storage.Customer = { + // If the specific customerID for that siteID already exists, return it + // If doesn't, insert a new one in Storage + if let storedCustomer = storage.loadCustomer(siteID: siteID, customerID: readOnlyCustomer.customerID) { + return storedCustomer + } else { + return storage.insertNewObject(ofType: Storage.Customer.self) + } + }() + storageCustomer.update(with: readOnlyCustomer) + } + + storageManager.saveDerivedType(derivedStorage: storage) { + DispatchQueue.main.async(execute: onCompletion) + } } } diff --git a/Yosemite/YosemiteTests/Stores/CustomerStoreTests.swift b/Yosemite/YosemiteTests/Stores/CustomerStoreTests.swift index ffecf0c24bc..458020ef3a6 100644 --- a/Yosemite/YosemiteTests/Stores/CustomerStoreTests.swift +++ b/Yosemite/YosemiteTests/Stores/CustomerStoreTests.swift @@ -1,11 +1,13 @@ import XCTest @testable import Networking @testable import Yosemite +@testable import Storage -class CustomerStoreTests: XCTestCase { +final class CustomerStoreTests: XCTestCase { private var dispatcher: Dispatcher! private var storageManager: MockStorageManager! + private var viewStorage: StorageType! private var network: MockNetwork! private var customerRemote: CustomerRemote! private var searchRemote: WCAnalyticsCustomerRemote! @@ -18,6 +20,7 @@ class CustomerStoreTests: XCTestCase { super.setUp() dispatcher = Dispatcher() storageManager = MockStorageManager() + viewStorage = storageManager.viewStorage network = MockNetwork() customerRemote = CustomerRemote(network: network) searchRemote = WCAnalyticsCustomerRemote(network: network) @@ -35,7 +38,7 @@ class CustomerStoreTests: XCTestCase { network.simulateResponse(requestUrlSuffix: "", filename: "customer") // When - let result: Result = waitFor { promise in + let result: Result = waitFor { promise in let action = CustomerAction.retrieveCustomer(siteID: self.dummySiteID, customerID: self.dummyCustomerID) { result in promise(result) } @@ -76,7 +79,7 @@ class CustomerStoreTests: XCTestCase { network.simulateError(requestUrlSuffix: "", error: expectedError) // When - let result: Result = waitFor { promise in + let result: Result = waitFor { promise in let action = CustomerAction.retrieveCustomer(siteID: self.dummySiteID, customerID: self.dummyCustomerID) { result in promise(result) } @@ -96,7 +99,7 @@ class CustomerStoreTests: XCTestCase { network.simulateResponse(requestUrlSuffix: "customers/3", filename: "customer") // When - let response: Result<[Customer], Error> = waitFor { promise in + let response: Result<[Networking.Customer], Error> = waitFor { promise in let action = CustomerAction.searchCustomers(siteID: self.dummySiteID, keyword: self.dummyKeyword) { result in promise(result) } @@ -114,7 +117,7 @@ class CustomerStoreTests: XCTestCase { network.simulateError(requestUrlSuffix: "", error: expectedError) // When - let result: Result<[Customer], Error> = waitFor { promise in + let result: Result<[Networking.Customer], Error> = waitFor { promise in let action = CustomerAction.searchCustomers( siteID: self.dummySiteID, keyword: self.dummyKeyword) { result in @@ -127,4 +130,58 @@ class CustomerStoreTests: XCTestCase { XCTAssertTrue(result.isFailure) XCTAssertEqual(result.failure as? NetworkError, expectedError) } + + func test_searchCustomers_upserts_the_returned_CustomerSearchResult() { + // Given + network.simulateResponse(requestUrlSuffix: "customers", filename: "wc-analytics-customers") + network.simulateResponse(requestUrlSuffix: "customers/1", filename: "customer") + network.simulateResponse(requestUrlSuffix: "customers/2", filename: "customer-2") + + XCTAssertEqual(viewStorage.countObjects(ofType: Storage.CustomerSearchResult.self), 0) + + // When + let response: Result<[Networking.Customer], Error> = waitFor { promise in + let action = CustomerAction.searchCustomers(siteID: self.dummySiteID, keyword: self.dummyKeyword) { result in + promise(result) + } + self.dispatcher.dispatch(action) + } + + // Then + XCTAssertTrue(response.isSuccess) + XCTAssertEqual(viewStorage.countObjects(ofType: Storage.Customer.self), 2) + XCTAssertEqual(viewStorage.countObjects(ofType: Storage.CustomerSearchResult.self), 1) + + let storedCustomerSearchResults = viewStorage.loadCustomerSearchResult(siteID: dummySiteID, keyword: dummyKeyword) + + XCTAssertNotNil(storedCustomerSearchResults) + XCTAssertEqual(storedCustomerSearchResults?.siteID, dummySiteID) + XCTAssertEqual(storedCustomerSearchResults?.keyword, dummyKeyword) + XCTAssertEqual(storedCustomerSearchResults?.customers?.count, 2) + XCTAssertTrue(storedCustomerSearchResults?.customers?.allSatisfy { $0.firstName?.contains(dummyKeyword) == true } ?? false ) + } + + func test_retrieveCustomer_upserts_the_returned_Customer() { + // Given + network.simulateResponse(requestUrlSuffix: "customers/25", filename: "customer") + XCTAssertEqual(viewStorage.countObjects(ofType: Storage.Customer.self), 0) + + // When + let result: Result = waitFor { promise in + let action = CustomerAction.retrieveCustomer(siteID: self.dummySiteID, customerID: self.dummyCustomerID) { result in + promise(result) + } + self.store.onAction(action) + } + + // Then + XCTAssertTrue(result.isSuccess) + XCTAssertEqual(viewStorage.countObjects(ofType: Storage.Customer.self), 1) + + let storedCustomer = viewStorage.loadCustomer(siteID: dummySiteID, customerID: dummyCustomerID) + XCTAssertNotNil(storedCustomer) + XCTAssertEqual(storedCustomer?.siteID, dummySiteID) + XCTAssertEqual(storedCustomer?.customerID, dummyCustomerID) + XCTAssertEqual(storedCustomer?.firstName, "John") + } }