diff --git a/Fakes/Fakes/Networking.generated.swift b/Fakes/Fakes/Networking.generated.swift index db61a3eb451..ef4ecdf3fd1 100644 --- a/Fakes/Fakes/Networking.generated.swift +++ b/Fakes/Fakes/Networking.generated.swift @@ -181,6 +181,20 @@ extension CreateProductVariation { ) } } +extension Customer { + /// Returns a "ready to use" type filled with fake values. + /// + public static func fake() -> Customer { + .init( + customerID: .fake(), + email: .fake(), + firstName: .fake(), + lastName: .fake(), + billing: .fake(), + shipping: .fake() + ) + } +} extension DotcomError { /// 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 6c5a38f9c97..55a4286b4c9 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -323,7 +323,13 @@ 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 */; }; + 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 */; }; + 68CB800E28D8901B00E169F8 /* CustomerMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68CB800D28D8901B00E169F8 /* CustomerMapper.swift */; }; + 68CB801028D89A0400E169F8 /* CustomerRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68CB800F28D89A0400E169F8 /* CustomerRemote.swift */; }; + 68CB801428D8A05200E169F8 /* CustomerMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68CB801328D8A05200E169F8 /* CustomerMapperTests.swift */; }; + 68CB801628D8A39700E169F8 /* customer.json in Resources */ = {isa = PBXBuildFile; fileRef = 68CB801528D8A39700E169F8 /* customer.json */; }; 68FBC5B828928C8C00A05461 /* WooFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 68FBC5B728928C8C00A05461 /* WooFoundation.framework */; }; 74002D6A2118B26100A63C19 /* SiteVisitStatsMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74002D692118B26000A63C19 /* SiteVisitStatsMapperTests.swift */; }; 74002D6C2118B88200A63C19 /* SiteVisitStatsRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74002D6B2118B88200A63C19 /* SiteVisitStatsRemoteTests.swift */; }; @@ -1010,7 +1016,13 @@ 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 = ""; }; + 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 = ""; }; + 68CB800D28D8901B00E169F8 /* CustomerMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerMapper.swift; sourceTree = ""; }; + 68CB800F28D89A0400E169F8 /* CustomerRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerRemote.swift; sourceTree = ""; }; + 68CB801328D8A05200E169F8 /* CustomerMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerMapperTests.swift; sourceTree = ""; }; + 68CB801528D8A39700E169F8 /* customer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = customer.json; sourceTree = ""; }; 68FBC5B728928C8C00A05461 /* WooFoundation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = WooFoundation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 69314EDE650855CAF927057E /* Pods_NetworkingTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NetworkingTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74002D692118B26000A63C19 /* SiteVisitStatsMapperTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiteVisitStatsMapperTests.swift; sourceTree = ""; }; @@ -1616,6 +1628,7 @@ FE28F6EB268436C9004465C7 /* UserRemoteTests.swift */, 077F39D926A58ED700ABEADC /* SystemStatusRemoteTests.swift */, DE34051E28BDFB0B00CF0D97 /* JetpackConnectionRemoteTests.swift */, + 68BD37B228D9B8BD00C2A517 /* CustomerRemoteTests.swift */, ); path = Remote; sourceTree = ""; @@ -1748,6 +1761,7 @@ FE28F6E5268429B6004465C7 /* UserRemote.swift */, 077F39D526A58E4500ABEADC /* SystemStatusRemote.swift */, AEF94584272974F2001DCCFB /* TelemetryRemote.swift */, + 68CB800F28D89A0400E169F8 /* CustomerRemote.swift */, ); path = Remote; sourceTree = ""; @@ -1855,6 +1869,7 @@ FE28F6E126840DED004465C7 /* User.swift */, DE50295828C5BD0200551736 /* JetpackUser.swift */, DE50295A28C5F99700551736 /* DotcomUser.swift */, + 68CB800B28D87BC800E169F8 /* Customer.swift */, ); path = Model; sourceTree = ""; @@ -2089,6 +2104,7 @@ 4513382727A96DE700AE5E78 /* inbox-note.json */, 0205021B27C86B9700FB1C6B /* inbox-note-without-isRead.json */, 68C87B332862D40E00A99054 /* setting-all-except-countries.json */, + 68CB801528D8A39700E169F8 /* customer.json */, ); path = Responses; sourceTree = ""; @@ -2181,6 +2197,7 @@ 02C112772742862600F4F0B4 /* WordPressSiteSettingsMapper.swift */, 0359EA1C27AADE000048DE2D /* WCPayChargeMapper.swift */, DE34051828BDEE6A00CF0D97 /* JetpackConnectionURLMapper.swift */, + 68CB800D28D8901B00E169F8 /* CustomerMapper.swift */, ); path = Mapper; sourceTree = ""; @@ -2303,6 +2320,7 @@ DE34051C28BDF1C900CF0D97 /* JetpackConnectionURLMapperTests.swift */, DE50296428C60A8000551736 /* JetpackUserMapperTests.swift */, 0359EA1E27AAE4680048DE2D /* WCPayChargeMapperTests.swift */, + 68CB801328D8A05200E169F8 /* CustomerMapperTests.swift */, ); path = Mapper; sourceTree = ""; @@ -2684,6 +2702,7 @@ 7497376A2141F2BE0008C490 /* top-performers-week-alt.json in Resources */, D865CE61278CA1AE002C8520 /* stripe-payment-intent-processing.json in Resources */, 743E84F222172D0A00FAC9D7 /* shipment_tracking_plugin_not_active.json in Resources */, + 68CB801628D8A39700E169F8 /* customer.json in Resources */, 451A97DE260B59870059D135 /* shipping-label-packages-success.json in Resources */, 31D27C8F2602B553002EDB1D /* plugins.json in Resources */, 261CF1B4255AD6B30090D8D3 /* payment-gateway-list.json in Resources */, @@ -2982,6 +3001,7 @@ B518662220A097C200037A38 /* Network.swift in Sources */, B572F69A21AC475C003EEFF0 /* DevicesRemote.swift in Sources */, 3192F220260D33BB0067FEF9 /* WCPayAccount.swift in Sources */, + 68CB800E28D8901B00E169F8 /* CustomerMapper.swift in Sources */, 45CCFCE227A2C9BF0012E8CB /* InboxNote.swift in Sources */, 311D412C2783BF7400052F64 /* StripeAccount.swift in Sources */, B518662420A099BF00037A38 /* AlamofireNetwork.swift in Sources */, @@ -3030,6 +3050,7 @@ 020D07BE23D8570800FD9580 /* MediaListMapper.swift in Sources */, 0359EA1327AAC6D00048DE2D /* WCPayCardPaymentDetails.swift in Sources */, CCB2CA9E262091CB00285CA0 /* SuccessDataResultMapper.swift in Sources */, + 68CB801028D89A0400E169F8 /* CustomerRemote.swift in Sources */, DE50295B28C5F99700551736 /* DotcomUser.swift in Sources */, 74C8F06820EEB7BD00B6EDC9 /* OrderNotesMapper.swift in Sources */, 24F98C582502EA8800F49B68 /* FeatureFlagMapper.swift in Sources */, @@ -3039,6 +3060,7 @@ 74046E1D217A6989007DD7BF /* SiteSetting.swift in Sources */, B5BB1D1020A237FB00112D92 /* Address.swift in Sources */, CE43066A23465F340073CBFF /* Refund.swift in Sources */, + 68CB800C28D87BC800E169F8 /* Customer.swift in Sources */, DE50295D28C6068B00551736 /* JetpackUserMapper.swift in Sources */, B524194121AC60A700D6FC0A /* DotcomDevice.swift in Sources */, D8EDFE2225EE88C9003D2213 /* ReaderConnectionToken.swift in Sources */, @@ -3124,6 +3146,7 @@ CEC4BF8F234E382F008D9195 /* RefundMapperTests.swift in Sources */, 24F98C5E2502EDCF00F49B68 /* BundleWooTests.swift in Sources */, 74AB0ACA21948CE4008220CD /* CommentResultMapperTests.swift in Sources */, + 68CB801428D8A05200E169F8 /* CustomerMapperTests.swift in Sources */, 02698CF824C183A5005337C4 /* ProductVariationListMapperTests.swift in Sources */, B524194921AC659500D6FC0A /* DevicesRemoteTests.swift in Sources */, 2685C0DA263B551300D9EE97 /* AddOnGroupMapperTests.swift in Sources */, @@ -3198,6 +3221,7 @@ 45CCFCE827A2E5020012E8CB /* InboxNoteListMapperTests.swift in Sources */, 74002D6C2118B88200A63C19 /* SiteVisitStatsRemoteTests.swift in Sources */, 0212683524C046CB00F8A892 /* MockNetwork+Path.swift in Sources */, + 68BD37B328D9B8BD00C2A517 /* CustomerRemoteTests.swift in Sources */, B554FA932180C17200C54DFF /* NoteHashListMapperTests.swift in Sources */, CC07866526790B1100BA9AC1 /* ShippingLabelPurchaseMapperTests.swift in Sources */, 74002D6A2118B26100A63C19 /* SiteVisitStatsMapperTests.swift in Sources */, diff --git a/Networking/Networking/Mapper/CustomerMapper.swift b/Networking/Networking/Mapper/CustomerMapper.swift new file mode 100644 index 00000000000..f8b3f443676 --- /dev/null +++ b/Networking/Networking/Mapper/CustomerMapper.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Mapper: Customer +/// +struct CustomerMapper: Mapper { + /// We're injecting this field by copying it in after parsing responses, because `siteID` is not returned in any of the Customer endpoints. + /// + let siteID: Int64 + + /// (Attempts) to convert a dictionary into a `Customer` entity + /// + func map(response: Data) throws -> Customer { + let decoder = JSONDecoder() + decoder.userInfo = [.siteID: siteID] + let customer = try decoder.decode(CustomerEnvelope.self, from: response).customer + return customer + } +} + +private struct CustomerEnvelope: Decodable { + let customer: Customer + + private enum CodingKeys: String, CodingKey { + case customer = "data" + } +} diff --git a/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift b/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift index 8eb39c4e25e..117dc48d6bc 100644 --- a/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift +++ b/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift @@ -169,6 +169,33 @@ extension CouponReport { } } +extension Customer { + func copy( + customerID: CopiableProp = .copy, + email: CopiableProp = .copy, + firstName: NullableCopiableProp = .copy, + lastName: NullableCopiableProp = .copy, + billing: NullableCopiableProp
= .copy, + shipping: NullableCopiableProp
= .copy + ) -> Customer { + let customerID = customerID ?? self.customerID + let email = email ?? self.email + let firstName = firstName ?? self.firstName + let lastName = lastName ?? self.lastName + let billing = billing ?? self.billing + let shipping = shipping ?? self.shipping + + return Customer( + customerID: customerID, + email: email, + firstName: firstName, + lastName: lastName, + billing: billing, + shipping: shipping + ) + } +} + extension DotcomUser { public func copy( id: CopiableProp = .copy, diff --git a/Networking/Networking/Model/Customer.swift b/Networking/Networking/Model/Customer.swift new file mode 100644 index 00000000000..70d57332fe8 --- /dev/null +++ b/Networking/Networking/Model/Customer.swift @@ -0,0 +1,76 @@ +import Foundation +import Codegen + +/// Represents a Customer entity: +/// https://woocommerce.github.io/woocommerce-rest-api-docs/#customer-properties +/// +public struct Customer: Codable, GeneratedCopiable, GeneratedFakeable { + + /// Unique identifier for the customer + public let customerID: Int64 + + /// The email address for the customer + public let email: String + + /// Customer first name + public let firstName: String? + + /// Customer last name + public let lastName: String? + + /// List of billing address data + public let billing: Address? + + /// List of shipping address data + public let shipping: Address? + + /// Customer struct initializer + /// + public init(customerID: Int64, + email: String, + firstName: String?, + lastName: String?, + billing: Address?, + shipping: Address?) { + self.customerID = customerID + self.email = email + self.firstName = firstName + self.lastName = lastName + self.billing = billing + self.shipping = shipping + } + + /// Public initializer for the Customer + /// + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let customerID = try container.decode(Int64.self, forKey: .customerID) + let email = try container.decode(String.self, forKey: .email) + let firstName = try container.decodeIfPresent(String.self, forKey: .firstName) + let lastName = try container.decodeIfPresent(String.self, forKey: .lastName) + let billing = try? container.decode(Address.self, forKey: .billing) + let shipping = try? container.decode(Address.self, forKey: .shipping) + + self.init(customerID: customerID, + email: email, + firstName: firstName, + lastName: lastName, + billing: billing, + shipping: shipping + ) + } +} + +/// Defines all of the Customer CodingKeys +/// +extension Customer { + enum CodingKeys: String, CodingKey { + case customerID = "id" + case email + case firstName = "first_name" + case lastName = "last_name" + case billing + case shipping + } +} diff --git a/Networking/Networking/Remote/CustomerRemote.swift b/Networking/Networking/Remote/CustomerRemote.swift new file mode 100644 index 00000000000..1df984ba788 --- /dev/null +++ b/Networking/Networking/Remote/CustomerRemote.swift @@ -0,0 +1,23 @@ +import Foundation + +public class CustomerRemote: Remote { + /// Retrieves a `Customer` + /// + /// - Parameters: + /// - customerID: ID of the customer that will be retrieved + /// - siteID: Site for which we'll fetch the customer. + /// - completion: Closure to be executed upon completion. + /// + func retrieveCustomer(for siteID: Int64, with customerID: Int64, completion: @escaping (Result) -> Void) { + let path = "/customers/\(customerID)" + let request = JetpackRequest(wooApiVersion: .mark3, + method: .get, + siteID: siteID, + path: path, + parameters: nil + ) + + let mapper = CustomerMapper(siteID: siteID) + enqueue(request, mapper: mapper, completion: completion) + } +} diff --git a/Networking/NetworkingTests/Mapper/CustomerMapperTests.swift b/Networking/NetworkingTests/Mapper/CustomerMapperTests.swift new file mode 100644 index 00000000000..5eab4ee1d8c --- /dev/null +++ b/Networking/NetworkingTests/Mapper/CustomerMapperTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import Networking + +/// CustomerMapper Unit Tests +/// +class CustomerMapperTests: XCTestCase { + + /// Dummy Site ID. + /// + private let dummySiteID: Int64 = 123 + + /// Local file that holds Customer data representing the API endpoint + /// + private let filename: String = "customer" + + /// Verifies that the Customer object can be mapped fron the Encoded data + /// + func test_Customer_is_mapped_from_encoded_data() { + // Given + let mapper = CustomerMapper(siteID: dummySiteID) + guard let data = Loader.contentsOf(filename) else { + XCTFail("customer.json not found") + return + } + + // When + let customer = try? mapper.map(response: data) + + // Then + XCTAssertNotNil(mapper) + XCTAssertNotNil(customer) + } + + /// Verifies that all of the Customer response values are parsed correctly + /// + func test_Customer_response_values_are_correctly_parsed() throws { + // Given + guard let customer = try mapCustomer(from: filename) else { + XCTFail() + return + } + + // Then + XCTAssertNotNil(customer) + XCTAssertEqual(customer.customerID, 25) + XCTAssertEqual(customer.email, "john.doe@example.com") + XCTAssertEqual(customer.firstName, "John") + XCTAssertEqual(customer.lastName, "Doe") + + let dummyAddresses = [customer.shipping, customer.billing].compactMap({ $0 }) + XCTAssertEqual(dummyAddresses.count, 2) + + for address in dummyAddresses { + XCTAssertEqual(address.firstName, "John") + XCTAssertEqual(address.lastName, "Doe") + XCTAssertEqual(address.company, "") + XCTAssertEqual(address.address1, "969 Market") + XCTAssertEqual(address.address2, "") + XCTAssertEqual(address.city, "San Francisco") + XCTAssertEqual(address.state, "CA") + XCTAssertEqual(address.postcode, "94103") + XCTAssertEqual(address.country, "US") + } + } +} + +private extension CustomerMapperTests { + /// Returns the CustomerMapper output upon receiving `filename` (Data Encoded) + /// + func mapCustomer(from filename: String) throws -> Customer? { + guard let response = Loader.contentsOf(filename) else { + return nil + } + return try! CustomerMapper(siteID: dummySiteID).map(response: response) + } +} diff --git a/Networking/NetworkingTests/Remote/CustomerRemoteTests.swift b/Networking/NetworkingTests/Remote/CustomerRemoteTests.swift new file mode 100644 index 00000000000..914f8c18113 --- /dev/null +++ b/Networking/NetworkingTests/Remote/CustomerRemoteTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import Networking + +class CustomerRemoteTests: XCTestCase { + + /// Dummy Network Wrapper + /// + private var network: MockNetwork! + + private var remote: CustomerRemote! + + /// Dummy Site ID + /// + private let sampleSiteID: Int64 = 123 + + /// Dummy Customer ID + private let sampleCustomerID: Int64 = 25 + + override func setUp() { + super.setUp() + network = MockNetwork() + remote = CustomerRemote(network: network) + } + + override func tearDown() { + network = nil + remote = nil + super.tearDown() + } + + /// Verifies that retrieveCustomer simulated response is successful for a given customerID + /// + func test_CustomerRemote_when_retrieveCustomer_then_returns_result_isSuccess() throws { + // Given + network.simulateResponse(requestUrlSuffix: "customers/\(sampleCustomerID)", filename: "customer") + + // When + let result = waitFor { promise in + self.remote.retrieveCustomer(for: self.sampleSiteID, with: self.sampleCustomerID) { result in + promise(result) + } + } + + // Then + XCTAssert(result.isSuccess) + } + + /// Verifies that retrieveCustomer properly parses the `wc/v3/customers/{customerID}` endpoint sample response. + /// + func test_retrieveCustomer_returns_parsed_customer_successfully() throws { + // Given + network.simulateResponse(requestUrlSuffix: "customers/\(sampleCustomerID)", filename: "customer") + + // When + let result = waitFor { promise in + self.remote.retrieveCustomer(for: self.sampleSiteID, with: self.sampleCustomerID) { result in + promise(result) + } + } + let customer = try XCTUnwrap(result.get()) + + // Then + XCTAssertNotNil(customer) + } +} diff --git a/Networking/NetworkingTests/Responses/customer.json b/Networking/NetworkingTests/Responses/customer.json new file mode 100644 index 00000000000..01b971bd4fb --- /dev/null +++ b/Networking/NetworkingTests/Responses/customer.json @@ -0,0 +1,53 @@ +{ + "data": { + "id": 25, + "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": "John", + "last_name": "Doe", + "role": "customer", + "username": "john.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" + } + ] + } + } +}