diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index c0e6c7d9c29..d9a4aa65a0d 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 020D07C223D858BB00FD9580 /* media-upload.json in Resources */ = {isa = PBXBuildFile; fileRef = 020D07C123D858BB00FD9580 /* media-upload.json */; }; 020D0C03291504DE00BB3DCE /* UnauthenticatedRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020D0C02291504DE00BB3DCE /* UnauthenticatedRequestTests.swift */; }; 0212683524C046CB00F8A892 /* MockNetwork+Path.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0212683424C046CB00F8A892 /* MockNetwork+Path.swift */; }; + 021940E2291E3CFD0090354E /* SiteRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021940E1291E3CFD0090354E /* SiteRemote.swift */; }; 0219B03923964BB3007DCD5E /* ProductShippingClassMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0219B03823964BB3007DCD5E /* ProductShippingClassMapper.swift */; }; 021A84DA257DF92800BC71D1 /* ShippingLabelRefundMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021A84D9257DF92800BC71D1 /* ShippingLabelRefundMapper.swift */; }; 021C7BF723863D1800A3BCBD /* Encodable+Serialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021C7BF623863D1800A3BCBD /* Encodable+Serialization.swift */; }; @@ -34,6 +35,9 @@ 025CA2C4238EBC4300B05C81 /* ProductShippingClassRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025CA2C3238EBC4300B05C81 /* ProductShippingClassRemote.swift */; }; 025CA2C6238F4F3500B05C81 /* ProductShippingClassRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025CA2C5238F4F3500B05C81 /* ProductShippingClassRemoteTests.swift */; }; 025CA2C8238F4FF400B05C81 /* product-shipping-classes-load-all.json in Resources */ = {isa = PBXBuildFile; fileRef = 025CA2C7238F4FF400B05C81 /* product-shipping-classes-load-all.json */; }; + 02616F8C292132800095BC00 /* SiteRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02616F8B292132800095BC00 /* SiteRemoteTests.swift */; }; + 02616F8F2921336C0095BC00 /* site-creation-domain-error.json in Resources */ = {isa = PBXBuildFile; fileRef = 02616F8D2921336C0095BC00 /* site-creation-domain-error.json */; }; + 02616F902921336C0095BC00 /* site-creation-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 02616F8E2921336C0095BC00 /* site-creation-success.json */; }; 0261F5A928D4641500B7AC72 /* products-sku-search.json in Resources */ = {isa = PBXBuildFile; fileRef = 0261F5A828D4641500B7AC72 /* products-sku-search.json */; }; 02698CF624C17FC1005337C4 /* product-alternative-types.json in Resources */ = {isa = PBXBuildFile; fileRef = 02698CF524C17FC1005337C4 /* product-alternative-types.json */; }; 02698CF824C183A5005337C4 /* ProductVariationListMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02698CF724C183A5005337C4 /* ProductVariationListMapperTests.swift */; }; @@ -764,6 +768,7 @@ 020D07C123D858BB00FD9580 /* media-upload.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-upload.json"; sourceTree = ""; }; 020D0C02291504DE00BB3DCE /* UnauthenticatedRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnauthenticatedRequestTests.swift; sourceTree = ""; }; 0212683424C046CB00F8A892 /* MockNetwork+Path.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MockNetwork+Path.swift"; sourceTree = ""; }; + 021940E1291E3CFD0090354E /* SiteRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteRemote.swift; sourceTree = ""; }; 0219B03823964BB3007DCD5E /* ProductShippingClassMapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProductShippingClassMapper.swift; sourceTree = ""; }; 021A84D9257DF92800BC71D1 /* ShippingLabelRefundMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelRefundMapper.swift; sourceTree = ""; }; 021C7BF623863D1800A3BCBD /* Encodable+Serialization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Encodable+Serialization.swift"; sourceTree = ""; }; @@ -779,6 +784,9 @@ 025CA2C3238EBC4300B05C81 /* ProductShippingClassRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductShippingClassRemote.swift; sourceTree = ""; }; 025CA2C5238F4F3500B05C81 /* ProductShippingClassRemoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductShippingClassRemoteTests.swift; sourceTree = ""; }; 025CA2C7238F4FF400B05C81 /* product-shipping-classes-load-all.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "product-shipping-classes-load-all.json"; sourceTree = ""; }; + 02616F8B292132800095BC00 /* SiteRemoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteRemoteTests.swift; sourceTree = ""; }; + 02616F8D2921336C0095BC00 /* site-creation-domain-error.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "site-creation-domain-error.json"; sourceTree = ""; }; + 02616F8E2921336C0095BC00 /* site-creation-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "site-creation-success.json"; sourceTree = ""; }; 0261F5A828D4641500B7AC72 /* products-sku-search.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "products-sku-search.json"; sourceTree = ""; }; 02698CF524C17FC1005337C4 /* product-alternative-types.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "product-alternative-types.json"; sourceTree = ""; }; 02698CF724C183A5005337C4 /* ProductVariationListMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationListMapperTests.swift; sourceTree = ""; }; @@ -1729,6 +1737,7 @@ 68BD37B228D9B8BD00C2A517 /* CustomerRemoteTests.swift */, 68F48B0E28E3BB850045C15B /* WCAnalyticsCustomerRemoteTests.swift */, 0239306A291A96F800B2632F /* DomainRemoteTests.swift */, + 02616F8B292132800095BC00 /* SiteRemoteTests.swift */, ); path = Remote; sourceTree = ""; @@ -1843,11 +1852,11 @@ 261CF1B7255AE62D0090D8D3 /* PaymentGatewayRemote.swift */, 45152808257A7C6E0076B03C /* ProductAttributesRemote.swift */, 26615472242D596B00A31661 /* ProductCategoriesRemote.swift */, + 026CF61F237D69D6009563D4 /* ProductVariationsRemote.swift */, 26E5A08725A66AFC000DF8F6 /* ProductAttributeTermRemote.swift */, D88D5A44230BC6F9007B6E01 /* ProductReviewsRemote.swift */, 4599FC5D24A62AA70056157A /* ProductTagsRemote.swift */, CE0A0F1A223989670075ED8D /* ProductsRemote.swift */, - 026CF61F237D69D6009563D4 /* ProductVariationsRemote.swift */, 025CA2C3238EBC4300B05C81 /* ProductShippingClassRemote.swift */, CE43066F234B99F50073CBFF /* RefundsRemote.swift */, 7412A8E921B6E192005D182A /* ReportRemote.swift */, @@ -1869,6 +1878,7 @@ 68CB800F28D89A0400E169F8 /* CustomerRemote.swift */, 68F48B0A28E3B1CD0045C15B /* WCAnalyticsCustomerRemote.swift */, 023930622918FF5400B2632F /* DomainRemote.swift */, + 021940E1291E3CFD0090354E /* SiteRemote.swift */, ); path = Remote; sourceTree = ""; @@ -2171,6 +2181,8 @@ 2670C3FD270F4E6A002FE931 /* sites-malformed.json */, 7426CA1221AF34A3004E9FFC /* site-api.json */, 74AB5B4E21AF3F0D00859C12 /* site-api-no-woo.json */, + 02616F8D2921336C0095BC00 /* site-creation-domain-error.json */, + 02616F8E2921336C0095BC00 /* site-creation-success.json */, CE50346621B5DCBE007573C6 /* site-plan.json */, 453305EC2459E1AA00264E50 /* site-post.json */, 453305F42459ED2700264E50 /* site-post-update.json */, @@ -2858,6 +2870,7 @@ 0359EA2927AC2AAD0048DE2D /* wcpay-charge-error.json in Resources */, CEF88DAB233E911A00BED485 /* order-fully-refunded.json in Resources */, 02698CFA24C188E9005337C4 /* product-variations-load-all-alternative-types.json in Resources */, + 02616F902921336C0095BC00 /* site-creation-success.json in Resources */, CC0786632678F79500BA9AC1 /* shipping-label-purchase-success.json in Resources */, 7497376A2141F2BE0008C490 /* top-performers-week-alt.json in Resources */, D865CE61278CA1AE002C8520 /* stripe-payment-intent-processing.json in Resources */, @@ -2887,6 +2900,7 @@ 028CB717290223CB00331C09 /* create-account-error-password.json in Resources */, D800DA0E25EFEC21001E13CE /* wcpay-connection-token.json in Resources */, 74159628224D63CE003C21CF /* settings-product-alt.json in Resources */, + 02616F8F2921336C0095BC00 /* site-creation-domain-error.json in Resources */, 45AB8B2024AB3E1F00B5B36E /* product-tags-empty.json in Resources */, 0359EA2127AAE58C0048DE2D /* wcpay-charge-card-present.json in Resources */, 451274A625276C82009911FF /* product-variation.json in Resources */, @@ -3044,6 +3058,7 @@ 74ABA1CD213F1B6B00FFAD30 /* TopEarnerStats.swift in Sources */, CCAAD10F2683974000909664 /* ShippingLabelPackagePurchase.swift in Sources */, 265EFBDC285257950033BD33 /* Order+Fallbacks.swift in Sources */, + 021940E2291E3CFD0090354E /* SiteRemote.swift in Sources */, B557DA0220975500005962F4 /* JetpackRequest.swift in Sources */, D88E229025AC990A0023F3B1 /* OrderFeeLine.swift in Sources */, 74046E1F217A6B70007DD7BF /* SiteSettingsMapper.swift in Sources */, @@ -3405,6 +3420,7 @@ DE50296528C60A8000551736 /* JetpackUserMapperTests.swift in Sources */, 9387A6F0226E3F15001B53D7 /* AccountSettingsMapperTests.swift in Sources */, 2685C102263B6A1000D9EE97 /* AddOnGroupRemoteTests.swift in Sources */, + 02616F8C292132800095BC00 /* SiteRemoteTests.swift in Sources */, B57B1E6721C916850046E764 /* NetworkErrorTests.swift in Sources */, D8FBFF0F22D3B25E006E3336 /* WooAPIVersionTests.swift in Sources */, 45152831257A8E1A0076B03C /* ProductAttributeMapperTests.swift in Sources */, diff --git a/Networking/Networking/Remote/SiteRemote.swift b/Networking/Networking/Remote/SiteRemote.swift new file mode 100644 index 00000000000..9aa75923323 --- /dev/null +++ b/Networking/Networking/Remote/SiteRemote.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Protocol for `SiteRemote` mainly used for mocking. +public protocol SiteRemoteProtocol { + /// Creates a site given + /// - Parameters: + /// - name: The name of the site. + /// - domain: The domain selected for the site. + /// - Returns: The result of site creation. + func createSite(name: String, + domain: String) async -> Result +} + +/// Site: Remote Endpoints +/// +public class SiteRemote: Remote, SiteRemoteProtocol { + private let dotcomClientID: String + private let dotcomClientSecret: String + + public init(network: Network, dotcomClientID: String, dotcomClientSecret: String) { + self.dotcomClientID = dotcomClientID + self.dotcomClientSecret = dotcomClientSecret + super.init(network: network) + } + + public func createSite(name: String, + domain: String) async -> Result { + let path = Path.siteCreation + + // Domain input should be a `wordpress.com` subdomain. + guard let subdomainName = domain.split(separator: ".").first else { + return .failure(SiteCreationError.invalidDomain) + } + let parameters: [String: Any] = [ + "blog_name": subdomainName, + "blog_title": name, + "client_id": dotcomClientID, + "client_secret": dotcomClientSecret, + "find_available_url": false, + "public": 0, + "validate": false, + "options": [ + "default_annotation_as_primary_fallback": true, + "site_creation_flow": "onboarding", + "site_information": [ + "title": "" + ], + "theme": "pub/zoologist", + "use_theme_annotation": false, + "wpcom_public_coming_soon": 1 + ] + ] + let request = DotcomRequest(wordpressApiVersion: .mark1_1, method: .post, path: path, parameters: parameters) + + do { + let response: SiteCreationResponse = try await enqueue(request) + return .success(response) + } catch { + return .failure(error) + } + } +} + +/// Site creation API response. +public struct SiteCreationResponse: Decodable { + public let site: Site + public let success: Bool + + private enum CodingKeys: String, CodingKey { + case site = "blog_details" + case success + } +} + +/// Possible site creation errors in the Networking layer. +public enum SiteCreationError: Error { + case invalidDomain +} + +public extension SiteCreationResponse { + /// Necessary data about the created site in the site creation API response. + struct Site: Decodable, Equatable { + public let siteID: String + public let name: String + public let url: String + public let siteSlug: String + + private enum CodingKeys: String, CodingKey { + case siteID = "blogid" + case name = "blogname" + case url + case siteSlug = "site_slug" + } + } +} + +// MARK: - Constants +// +private extension SiteRemote { + enum Path { + static let siteCreation = "sites/new" + } +} diff --git a/Networking/NetworkingTests/Remote/SiteRemoteTests.swift b/Networking/NetworkingTests/Remote/SiteRemoteTests.swift new file mode 100644 index 00000000000..01a6d857419 --- /dev/null +++ b/Networking/NetworkingTests/Remote/SiteRemoteTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import Networking + +final class SiteRemoteTests: XCTestCase { + /// Mock network wrapper. + private var network: MockNetwork! + + private var remote: SiteRemote! + + override func setUp() { + super.setUp() + network = MockNetwork() + remote = SiteRemote(network: network, dotcomClientID: "", dotcomClientSecret: "") + } + + override func tearDown() { + remote = nil + network = nil + super.tearDown() + } + + func test_createSite_returns_created_site_on_success() async throws { + // Given + network.simulateResponse(requestUrlSuffix: "sites/new", filename: "site-creation-success") + + // When + let result = await remote.createSite(name: "Wapuu swags", domain: "wapuu.store") + + // Then + XCTAssertTrue(result.isSuccess) + let response = try XCTUnwrap(result.get()) + XCTAssertTrue(response.success) + XCTAssertEqual(response.site, .init(siteID: "202211", + name: "Wapuu swags", + url: "https://wapuu.store/", + siteSlug: "wapuu.store")) + } + + func test_createSite_returns_invalidDomain_error_when_domain_is_empty() async throws { + // When + let result = await remote.createSite(name: "Wapuu swags", domain: "") + + // Then + let error = try XCTUnwrap(result.failure as? SiteCreationError) + XCTAssertEqual(error, .invalidDomain) + } + + func test_createSite_returns_DotcomError_failure_on_domain_error() async throws { + // Given + network.simulateResponse(requestUrlSuffix: "sites/new", filename: "site-creation-domain-error") + + // When + let result = await remote.createSite(name: "Wapuu swags", domain: "wapuu.store") + + // Then + let error = try XCTUnwrap(result.failure as? DotcomError) + XCTAssertEqual(error, + .unknown(code: "blog_name_only_lowercase_letters_and_numbers", + message: "Site names can only contain lowercase letters (a-z) and numbers.")) + } + + func test_createSite_returns_failure_on_empty_response() async throws { + // When + let result = await remote.createSite(name: "Wapuu swags", domain: "wapuu.store") + + // Then + let error = try XCTUnwrap(result.failure as? NetworkError) + XCTAssertEqual(error, .notFound) + } +} diff --git a/Networking/NetworkingTests/Responses/site-creation-domain-error.json b/Networking/NetworkingTests/Responses/site-creation-domain-error.json new file mode 100644 index 00000000000..b6e488916fe --- /dev/null +++ b/Networking/NetworkingTests/Responses/site-creation-domain-error.json @@ -0,0 +1,4 @@ +{ + "error": "blog_name_only_lowercase_letters_and_numbers", + "message": "Site names can only contain lowercase letters (a-z) and numbers." +} diff --git a/Networking/NetworkingTests/Responses/site-creation-success.json b/Networking/NetworkingTests/Responses/site-creation-success.json new file mode 100644 index 00000000000..777a2cfdf64 --- /dev/null +++ b/Networking/NetworkingTests/Responses/site-creation-success.json @@ -0,0 +1,10 @@ +{ + "success": true, + "blog_details": { + "url": "https://wapuu.store/", + "blogid": "202211", + "blogname": "Wapuu swags", + "xmlrpc": "https://adventuresofwapuu.wordpress.com/xmlrpc.php", + "site_slug": "wapuu.store" + } +} diff --git a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift index 95f109e1bb3..3ce1c6ef960 100644 --- a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift @@ -63,6 +63,11 @@ class AuthenticatedState: StoresManagerState { ShippingLabelStore(dispatcher: dispatcher, storageManager: storageManager, network: network), SitePluginStore(dispatcher: dispatcher, storageManager: storageManager, network: network), SitePostStore(dispatcher: dispatcher, storageManager: storageManager, network: network), + SiteStore(dotcomClientID: ApiCredentials.dotcomAppId, + dotcomClientSecret: ApiCredentials.dotcomSecret, + dispatcher: dispatcher, + storageManager: storageManager, + network: network), StatsStoreV4(dispatcher: dispatcher, storageManager: storageManager, network: network), SystemStatusStore(dispatcher: dispatcher, storageManager: storageManager, network: network), TaxClassStore(dispatcher: dispatcher, storageManager: storageManager, network: network), diff --git a/Yosemite/Yosemite.xcodeproj/project.pbxproj b/Yosemite/Yosemite.xcodeproj/project.pbxproj index 8ae110d33da..fb04efca244 100644 --- a/Yosemite/Yosemite.xcodeproj/project.pbxproj +++ b/Yosemite/Yosemite.xcodeproj/project.pbxproj @@ -26,6 +26,8 @@ 0218B4EE242E08B20083A847 /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0218B4ED242E08B20083A847 /* MediaType.swift */; }; 0218B4F0242E091C0083A847 /* Media+MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0218B4EF242E091C0083A847 /* Media+MediaType.swift */; }; 0218B4F2242E09E80083A847 /* MediaTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0218B4F1242E09E80083A847 /* MediaTypeTests.swift */; }; + 021940E4291E8A660090354E /* SiteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021940E3291E8A660090354E /* SiteAction.swift */; }; + 021940E6291E8AD80090354E /* SiteStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021940E5291E8AD80090354E /* SiteStore.swift */; }; 021BA0C428576940006E9886 /* MockDotcomAccountRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021BA0C328576940006E9886 /* MockDotcomAccountRemote.swift */; }; 021EAA5C25493E9300AA8CCD /* OrderItemAttribute+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021EAA5B25493E9300AA8CCD /* OrderItemAttribute+ReadOnlyConvertible.swift */; }; 0225512122FC2F3000D98613 /* OrderStatsV4Interval+Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0225512022FC2F3000D98613 /* OrderStatsV4Interval+Date.swift */; }; @@ -49,6 +51,8 @@ 025CA2CC238F518600B05C81 /* ProductShippingClassAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025CA2CB238F518600B05C81 /* ProductShippingClassAction.swift */; }; 025CA2CE238F53CB00B05C81 /* ProductShippingClass+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025CA2CD238F53CB00B05C81 /* ProductShippingClass+ReadOnlyConvertible.swift */; }; 025CA2D0238F54E800B05C81 /* ProductShippingClassStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025CA2CF238F54E800B05C81 /* ProductShippingClassStoreTests.swift */; }; + 02616F922921E1530095BC00 /* SiteStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02616F912921E1530095BC00 /* SiteStoreTests.swift */; }; + 02616F942921E1CD0095BC00 /* MockSiteRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02616F932921E1CD0095BC00 /* MockSiteRemote.swift */; }; 026CF626237D8EFB009563D4 /* ProductVariationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 026CF625237D8EFB009563D4 /* ProductVariationStore.swift */; }; 026CF628237D8F30009563D4 /* ProductVariationAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 026CF627237D8F30009563D4 /* ProductVariationAction.swift */; }; 026CF62A237D92C6009563D4 /* ProductVariation+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 026CF629237D92C6009563D4 /* ProductVariation+ReadOnlyConvertible.swift */; }; @@ -447,6 +451,8 @@ 0218B4ED242E08B20083A847 /* MediaType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaType.swift; sourceTree = ""; }; 0218B4EF242E091C0083A847 /* Media+MediaType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Media+MediaType.swift"; sourceTree = ""; }; 0218B4F1242E09E80083A847 /* MediaTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTypeTests.swift; sourceTree = ""; }; + 021940E3291E8A660090354E /* SiteAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteAction.swift; sourceTree = ""; }; + 021940E5291E8AD80090354E /* SiteStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteStore.swift; sourceTree = ""; }; 021BA0C328576940006E9886 /* MockDotcomAccountRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDotcomAccountRemote.swift; sourceTree = ""; }; 021EAA5B25493E9300AA8CCD /* OrderItemAttribute+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OrderItemAttribute+ReadOnlyConvertible.swift"; sourceTree = ""; }; 0225512022FC2F3000D98613 /* OrderStatsV4Interval+Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OrderStatsV4Interval+Date.swift"; sourceTree = ""; }; @@ -470,6 +476,8 @@ 025CA2CB238F518600B05C81 /* ProductShippingClassAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductShippingClassAction.swift; sourceTree = ""; }; 025CA2CD238F53CB00B05C81 /* ProductShippingClass+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProductShippingClass+ReadOnlyConvertible.swift"; sourceTree = ""; }; 025CA2CF238F54E800B05C81 /* ProductShippingClassStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductShippingClassStoreTests.swift; sourceTree = ""; }; + 02616F912921E1530095BC00 /* SiteStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteStoreTests.swift; sourceTree = ""; }; + 02616F932921E1CD0095BC00 /* MockSiteRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSiteRemote.swift; sourceTree = ""; }; 026CF625237D8EFB009563D4 /* ProductVariationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationStore.swift; sourceTree = ""; }; 026CF627237D8F30009563D4 /* ProductVariationAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationAction.swift; sourceTree = ""; }; 026CF629237D92C6009563D4 /* ProductVariation+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProductVariation+ReadOnlyConvertible.swift"; sourceTree = ""; }; @@ -1197,6 +1205,7 @@ 029249E7274B8AEE002E9C34 /* MockMediaRemote.swift */, 021BA0C328576940006E9886 /* MockDotcomAccountRemote.swift */, 02DAE7F9291A9F36009342B7 /* MockDomainRemote.swift */, + 02616F932921E1CD0095BC00 /* MockSiteRemote.swift */, ); path = Remote; sourceTree = ""; @@ -1409,6 +1418,7 @@ 68BD37B428DB2E9800C2A517 /* CustomerStore.swift */, 02E3B622290267D3007E0F13 /* AccountCreationStore.swift */, 02393066291A02AC00B2632F /* DomainStore.swift */, + 021940E5291E8AD80090354E /* SiteStore.swift */, ); path = Stores; sourceTree = ""; @@ -1469,6 +1479,7 @@ 68BD37B828DB323D00C2A517 /* CustomerStoreTests.swift */, 02E3B629290622DE007E0F13 /* AccountCreationStoreTests.swift */, 02DAE7F7291A9F11009342B7 /* DomainStoreTests.swift */, + 02616F912921E1530095BC00 /* SiteStoreTests.swift */, ); path = Stores; sourceTree = ""; @@ -1655,6 +1666,7 @@ DE3404FB28BC5E7800CF0D97 /* JetpackConnectionAction.swift */, 02E3B624290267F2007E0F13 /* AccountCreationAction.swift */, 02393064291A018600B2632F /* DomainAction.swift */, + 021940E3291E8A660090354E /* SiteAction.swift */, ); path = Actions; sourceTree = ""; @@ -2031,6 +2043,7 @@ 45739F372437680F00480C95 /* ProductSettings.swift in Sources */, 247CE88725833F1200F9D9D1 /* MockObjectGraph.swift in Sources */, 741F34822195EA71005F5BD9 /* CommentStore.swift in Sources */, + 021940E4291E8A660090354E /* SiteAction.swift in Sources */, 02291735270BE18C00449FA0 /* ProductReviewFromNoteParcel.swift in Sources */, 74B260212188B5F30041793A /* Note+ReadOnlyType.swift in Sources */, B505254C20EE6491008090F5 /* Site+ReadOnlyConvertible.swift in Sources */, @@ -2046,6 +2059,7 @@ 74A7688C20D45EBA00F9D437 /* OrderStore.swift in Sources */, DE3404FE28BC5F4200CF0D97 /* JetpackConnectionStore.swift in Sources */, 2618707C2540B6A4006522A1 /* ShippingLineTax+ReadOnlyConvertible.swift in Sources */, + 021940E6291E8AD80090354E /* SiteStore.swift in Sources */, 749375002249605E007D85D1 /* ProductAction.swift in Sources */, D831E2E4230E3524000037D0 /* ProductReviewAction.swift in Sources */, 02393065291A018600B2632F /* DomainAction.swift in Sources */, @@ -2193,6 +2207,7 @@ 02E3B62A290622DE007E0F13 /* AccountCreationStoreTests.swift in Sources */, 0202B6992387B01500F3EBE0 /* AppSettingsStoreTests+ProductsFeatureSwitch.swift in Sources */, 5779A0CD250042B600E35AF2 /* FetchResultSnapshotsProviderTests.swift in Sources */, + 02616F942921E1CD0095BC00 /* MockSiteRemote.swift in Sources */, 022F9319257F24730011CD94 /* MockShippingLabel.swift in Sources */, 02FF055623D984310058E6E7 /* MockFileManager.swift in Sources */, 029B00A7230D64E800B0AE66 /* StatsTimeRangeTests.swift in Sources */, @@ -2226,6 +2241,7 @@ 020B2F9623BDE4DD00BD79AD /* ProductStoreTests+Validation.swift in Sources */, 02E262C0238CE80100B79588 /* StorageShippingSettingsServiceTests.swift in Sources */, 45AB8B1E24AB363D00B5B36E /* ProductTagStoreTests.swift in Sources */, + 02616F922921E1530095BC00 /* SiteStoreTests.swift in Sources */, B5C9DE222087FF20006B910A /* DispatcherTests.swift in Sources */, 45182D2327B55F9C00B4C05C /* InboxNotesStoreTests.swift in Sources */, 03FBDA2A263296C400ACE257 /* CouponStoreTests.swift in Sources */, diff --git a/Yosemite/Yosemite/Actions/SiteAction.swift b/Yosemite/Yosemite/Actions/SiteAction.swift new file mode 100644 index 00000000000..e3fd0d29d78 --- /dev/null +++ b/Yosemite/Yosemite/Actions/SiteAction.swift @@ -0,0 +1,22 @@ +import Foundation + +/// SiteAction: Defines all of the Actions supported by the SiteStore. +/// +public enum SiteAction: Action { + /// Creates a site in the store creation flow. + /// - Parameters: + /// - name: The name of the site. + /// - domain: Domain name selected for the site. + /// - completion: The result of site creation. + case createSite(name: String, + domain: String, + completion: (Result) -> Void) +} + +/// The result of site creation including necessary site information. +public struct SiteCreationResult: Equatable { + public let siteID: Int64 + public let name: String + public let url: String + public let siteSlug: String +} diff --git a/Yosemite/Yosemite/Stores/SiteStore.swift b/Yosemite/Yosemite/Stores/SiteStore.swift new file mode 100644 index 00000000000..b2be5532a85 --- /dev/null +++ b/Yosemite/Yosemite/Stores/SiteStore.swift @@ -0,0 +1,115 @@ +import Foundation +import Networking +import protocol Storage.StorageManagerType + +/// Handles `SiteAction` +/// +public final class SiteStore: Store { + // Keeps a strong reference to remote to keep requests alive. + private let remote: SiteRemoteProtocol + + public init(remote: SiteRemoteProtocol, + dispatcher: Dispatcher, + storageManager: StorageManagerType, + network: Network) { + self.remote = remote + super.init(dispatcher: dispatcher, storageManager: storageManager, network: network) + } + + public convenience init(dotcomClientID: String, + dotcomClientSecret: String, + dispatcher: Dispatcher, + storageManager: StorageManagerType, + network: Network) { + let remote = SiteRemote(network: network, dotcomClientID: dotcomClientID, dotcomClientSecret: dotcomClientSecret) + self.init(remote: remote, + dispatcher: dispatcher, + storageManager: storageManager, + network: network) + } + + public override func registerSupportedActions(in dispatcher: Dispatcher) { + dispatcher.register(processor: self, for: SiteAction.self) + } + + /// Called whenever a given Action is dispatched. + /// + public override func onAction(_ action: Action) { + guard let action = action as? SiteAction else { + assertionFailure("SiteStore received an unsupported action: \(action)") + return + } + switch action { + case .createSite(let name, let domain, let completion): + createSite(name: name, domain: domain, completion: completion) + } + } +} + +private extension SiteStore { + func createSite(name: String, + domain: String, + completion: @escaping (Result) -> Void) { + Task { @MainActor in + let result = await remote.createSite(name: name, + domain: domain) + switch result { + case .success(let response): + guard response.success else { + return completion(.failure(SiteCreationError.unsuccessful)) + } + guard let siteID = Int64(response.site.siteID) else { + return completion(.failure(SiteCreationError.invalidSiteID)) + } + completion(.success(.init(siteID: siteID, + name: response.site.name, + url: response.site.url, + siteSlug: response.site.siteSlug))) + case .failure(let remoteError): + completion(.failure(SiteCreationError(remoteError: remoteError))) + } + } + } +} + +/// Possible site creation errors. +public enum SiteCreationError: Error, Equatable { + /// The domain name should be a `wordpress.com` subdomain and can only contain lowercase letters (a-z) and numbers. + case invalidDomain + /// The domain has been taken. + case domainExists + /// The returned site ID for the created site is invalid - for example, not a string that can be converted to `Int64`. + case invalidSiteID + /// When the site creation result is returned but its `success` boolean is `false`. + case unsuccessful + /// Unexpected error from WPCOM. + case unexpected(error: DotcomError) + /// Unknown error that is not a `DotcomError` nor `Networking.SiteCreationError`. + case unknown(description: String) + + init(remoteError: Error) { + switch remoteError { + case let remoteError as Networking.SiteCreationError: + switch remoteError { + case .invalidDomain: + self = .invalidDomain + } + case let remoteError as DotcomError: + switch remoteError { + case let .unknown(code, _): + switch code { + case "blog_name_exists": + self = .domainExists + case "blog_name_only_lowercase_letters_and_numbers": + self = .invalidDomain + default: + self = .unexpected(error: remoteError) + } + default: + self = .unexpected(error: remoteError) + } + default: + self = .unknown(description: remoteError.localizedDescription) + } + } +} diff --git a/Yosemite/YosemiteTests/Base/DispatcherTests.swift b/Yosemite/YosemiteTests/Base/DispatcherTests.swift index d086aa5463b..b07c4eec52f 100644 --- a/Yosemite/YosemiteTests/Base/DispatcherTests.swift +++ b/Yosemite/YosemiteTests/Base/DispatcherTests.swift @@ -21,17 +21,17 @@ class DispatcherTests: XCTestCase { /// func testProcessorEffectivelyGetsRegistered() { let processor = MockActionsProcessor() - dispatcher.register(processor: processor, for: SiteAction.self) - XCTAssertTrue(dispatcher.isProcessorRegistered(processor, for: SiteAction.self)) + dispatcher.register(processor: processor, for: MockSiteAction.self) + XCTAssertTrue(dispatcher.isProcessorRegistered(processor, for: MockSiteAction.self)) } /// Verifies that a processor only receives the actions it's been registered to. /// func testProcessorsReceiveOnlyRegisteredActions() { - dispatcher.register(processor: processor, for: SiteAction.self) + dispatcher.register(processor: processor, for: MockSiteAction.self) XCTAssertTrue(processor.receivedActions.isEmpty) - dispatcher.dispatch(SiteAction.refreshSites) + dispatcher.dispatch(MockSiteAction.refreshSites) XCTAssertEqual(processor.receivedActions.count, 1) dispatcher.dispatch(MockAccountAction.authenticate) @@ -41,13 +41,13 @@ class DispatcherTests: XCTestCase { /// Verifies that a registered processor receive all of the posted actions. /// func testProcessorsReceiveRegisteredActions() { - dispatcher.register(processor: processor, for: SiteAction.self) + dispatcher.register(processor: processor, for: MockSiteAction.self) XCTAssertTrue(processor.receivedActions.isEmpty) - dispatcher.dispatch(SiteAction.refreshSites) + dispatcher.dispatch(MockSiteAction.refreshSites) XCTAssertEqual(processor.receivedActions.count, 1) - dispatcher.dispatch(SiteAction.refreshSite(identifier: 123)) + dispatcher.dispatch(MockSiteAction.refreshSite(identifier: 123)) XCTAssertEqual(processor.receivedActions.count, 2) } @@ -56,12 +56,12 @@ class DispatcherTests: XCTestCase { func testUnregisteredProcessorsDoNotReceiveAnyActions() { XCTAssertTrue(processor.receivedActions.isEmpty) - dispatcher.register(processor: processor, for: SiteAction.self) - dispatcher.dispatch(SiteAction.refreshSites) + dispatcher.register(processor: processor, for: MockSiteAction.self) + dispatcher.dispatch(MockSiteAction.refreshSites) XCTAssertEqual(processor.receivedActions.count, 1) dispatcher.unregister(processor: processor) - dispatcher.dispatch(SiteAction.refreshSites) + dispatcher.dispatch(MockSiteAction.refreshSites) dispatcher.dispatch(MockAccountAction.authenticate) XCTAssertEqual(processor.receivedActions.count, 1) } @@ -69,10 +69,10 @@ class DispatcherTests: XCTestCase { /// Verifies that the Dispatcher does not strongly retain the ActionsProcessors. /// func testProcessorsAreNotStronglyRetainedByDispatcher() { - dispatcher.register(processor: processor, for: SiteAction.self) - XCTAssertNotNil(dispatcher.processor(for: SiteAction.self)) + dispatcher.register(processor: processor, for: MockSiteAction.self) + XCTAssertNotNil(dispatcher.processor(for: MockSiteAction.self)) processor = nil - XCTAssertNil(dispatcher.processor(for: SiteAction.self)) + XCTAssertNil(dispatcher.processor(for: MockSiteAction.self)) } } diff --git a/Yosemite/YosemiteTests/Mocks/MockSiteStore.swift b/Yosemite/YosemiteTests/Mocks/MockSiteStore.swift index 18bb13dd3a9..fc9402ed67a 100644 --- a/Yosemite/YosemiteTests/Mocks/MockSiteStore.swift +++ b/Yosemite/YosemiteTests/Mocks/MockSiteStore.swift @@ -4,7 +4,7 @@ import Yosemite // MARK: - Represents a Site Action. // -enum SiteAction: Action { +enum MockSiteAction: Action { case refreshSite(identifier: Int) case refreshSites } @@ -14,14 +14,14 @@ enum SiteAction: Action { // class MockSiteStore: Store { - var receivedActions = [SiteAction]() + var receivedActions = [MockSiteAction]() override func registerSupportedActions(in dispatcher: Dispatcher) { - dispatcher.register(processor: self, for: SiteAction.self) + dispatcher.register(processor: self, for: MockSiteAction.self) } override func onAction(_ action: Action) { - guard let accountAction = action as? SiteAction else { + guard let accountAction = action as? MockSiteAction else { return } diff --git a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockSiteRemote.swift b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockSiteRemote.swift new file mode 100644 index 00000000000..411777231a9 --- /dev/null +++ b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockSiteRemote.swift @@ -0,0 +1,24 @@ +import Networking +import XCTest + +/// Mock for `SiteRemote`. +/// +final class MockSiteRemote { + /// The results to return in `createSite`. + private var createSiteResult: Result? + + /// Returns the value when `createSite` is called. + func whenCreatingSite(thenReturn result: Result) { + createSiteResult = result + } +} + +extension MockSiteRemote: SiteRemoteProtocol { + func createSite(name: String, domain: String) async -> Result { + guard let result = createSiteResult else { + XCTFail("Could not find result for creating a site.") + return .failure(NetworkError.notFound) + } + return result + } +} diff --git a/Yosemite/YosemiteTests/Stores/SiteStoreTests.swift b/Yosemite/YosemiteTests/Stores/SiteStoreTests.swift new file mode 100644 index 00000000000..1be4e261cbd --- /dev/null +++ b/Yosemite/YosemiteTests/Stores/SiteStoreTests.swift @@ -0,0 +1,151 @@ +import XCTest +import enum Networking.DotcomError +import enum Networking.SiteCreationError +@testable import class Networking.MockNetwork +@testable import Yosemite + +final class SiteStoreTests: XCTestCase { + /// Mock Dispatcher. + private var dispatcher: Dispatcher! + + /// Mock Storage: InMemory. + private var storageManager: MockStorageManager! + + /// Mock Network: Allows us to inject predefined responses. + private var network: Networking.MockNetwork! + + private var remote: MockSiteRemote! + private var store: SiteStore! + + override func setUp() { + super.setUp() + dispatcher = Dispatcher() + storageManager = MockStorageManager() + network = MockNetwork() + remote = MockSiteRemote() + store = SiteStore(remote: remote, + dispatcher: dispatcher, + storageManager: storageManager, + network: network) + } + + override func tearDown() { + store = nil + remote = nil + network = nil + storageManager = nil + dispatcher = nil + super.tearDown() + } + + // MARK: - `createSite` + + func test_createSite_returns_site_result_on_success() throws { + // Given + remote.whenCreatingSite(thenReturn: .success( + .init(site: .init(siteID: "134", + name: "Salsa verde", + url: "https://salsa.verde/", + siteSlug: "salsa.verde"), + success: true))) + + // When + let result = waitFor { promise in + self.store.onAction(SiteAction.createSite(name: "Salsa", + domain: "salsa.roja", + completion: { result in + promise(result) + })) + } + + // Then + XCTAssertTrue(result.isSuccess) + let siteResult = try XCTUnwrap(result.get()) + XCTAssertEqual(siteResult, .init(siteID: 134, name: "Salsa verde", url: "https://salsa.verde/", siteSlug: "salsa.verde")) + } + + func test_createSite_returns_unsuccessful_error_on_false_success() throws { + // Given + remote.whenCreatingSite(thenReturn: .success( + .init(site: .init(siteID: "134", + name: "Salsa verde", + url: "https://salsa.verde/", + siteSlug: "salsa.verde"), + // Success flag is `false` for some reason. + success: false))) + + // When + let result = waitFor { promise in + self.store.onAction(SiteAction.createSite(name: "Salsa", + domain: "salsa.roja", + completion: { result in + promise(result) + })) + } + + // Then + let error = try XCTUnwrap(result.failure) + XCTAssertEqual(error, .unsuccessful) + } + + func test_createSite_returns_invalidDomain_error_on_Networking_domain_error() throws { + // Given + remote.whenCreatingSite(thenReturn: .failure( + Networking.SiteCreationError.invalidDomain + )) + + // When + let result = waitFor { promise in + self.store.onAction(SiteAction.createSite(name: "Salsa", + domain: "salsa.roja", + completion: { result in + promise(result) + })) + } + + // Then + let error = try XCTUnwrap(result.failure) + XCTAssertEqual(error, .invalidDomain) + } + + func test_createSite_returns_domainExists_error_on_Dotcom_blog_name_exists_error() throws { + // Given + remote.whenCreatingSite(thenReturn: .failure( + DotcomError.unknown(code: "blog_name_exists", message: "Sorry, that site already exists!") + )) + + // When + let result = waitFor { promise in + self.store.onAction(SiteAction.createSite(name: "Salsa", + domain: "salsa.roja", + completion: { result in + promise(result) + })) + } + + // Then + let error = try XCTUnwrap(result.failure) + XCTAssertEqual(error, .domainExists) + } + + func test_createSite_returns_invalidDomain_error_on_Dotcom_blog_name_error() throws { + // Given + remote.whenCreatingSite(thenReturn: .failure( + DotcomError.unknown(code: "blog_name_only_lowercase_letters_and_numbers", + message: "Site names can only contain lowercase letters (a-z) and numbers.") + )) + + // When + let result = waitFor { promise in + self.store.onAction(SiteAction.createSite(name: "Salsa", + domain: "salsa.roja", + completion: { result in + promise(result) + })) + } + + // Then + let error = try XCTUnwrap(result.failure) + XCTAssertEqual(error, .invalidDomain) + } +}