diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 718fbe84fd7c..47601a1c1f88 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,5 +1,6 @@ 27.2 ----- +* [*] Stats: Fix the screen getting stuck on a loading indicator for self-hosted sites that are not connected to Jetpack [#25858] 27.1 diff --git a/Tests/KeystoneTests/Tests/Services/BlogServiceSyncTests.swift b/Tests/KeystoneTests/Tests/Services/BlogServiceSyncTests.swift new file mode 100644 index 000000000000..63ea2da0bb6b --- /dev/null +++ b/Tests/KeystoneTests/Tests/Services/BlogServiceSyncTests.swift @@ -0,0 +1,295 @@ +import Foundation +import OHHTTPStubs +import OHHTTPStubsSwift +import Testing +import WordPressData +import WordPressKit +import XCTest + +@testable import WordPress + +@Suite(.serialized) +struct BlogServiceSyncTests { + @Test @MainActor + func coreRESTBlogSyncsOptionsUsingApplicationPassword() async throws { + let host = "\(UUID().uuidString.lowercased()).example.com" + let coreDataStack = ContextManager.forTesting() + let blog = makeSelfHostedBlog(in: coreDataStack, host: host, usesCoreREST: true) + try blog.setApplicationToken("application-password") + defer { try? blog.deleteApplicationToken() } + coreDataStack.saveContextAndWait(coreDataStack.mainContext) + + let stubDescriptor = stub(condition: isHost(host) && isPath("/xmlrpc.php")) { request in + let body = request.httpBodyStream?.read().flatMap { String(data: $0, encoding: .utf8) } + #expect(body?.contains("application-password") == true) + return HTTPStubsResponse( + data: Self.optionsResponse, + statusCode: 200, + headers: ["Content-Type": "text/xml; charset=UTF-8"] + ) + } + defer { HTTPStubs.removeStub(stubDescriptor) } + + let service = BlogService(coreDataStack: coreDataStack) + let callbacks = await waitForCallbacks(from: service, blog: blog) + + #expect(callbacks.count == 1) + guard case .success = callbacks.first else { + Issue.record("Expected syncBlog to invoke its success callback, got \(callbacks)") + return + } + #expect(blog.getOptionString(name: "jetpack_version") == "15.0") + } + + @Test @MainActor + func coreRESTBlogFailureUpdatesXMLRPCAvailabilityAndCompletesOnce() async throws { + let host = "\(UUID().uuidString.lowercased()).example.com" + let coreDataStack = ContextManager.forTesting() + let blog = makeSelfHostedBlog(in: coreDataStack, host: host, usesCoreREST: true) + try blog.setApplicationToken("application-password") + defer { try? blog.deleteApplicationToken() } + coreDataStack.saveContextAndWait(coreDataStack.mainContext) + + let stubDescriptor = stub(condition: isHost(host) && isPath("/xmlrpc.php")) { _ in + HTTPStubsResponse( + data: Data("Not found".utf8), + statusCode: 404, + headers: ["Content-Type": "text/html"] + ) + } + defer { HTTPStubs.removeStub(stubDescriptor) } + + let service = BlogService(coreDataStack: coreDataStack) + let callbacks = await waitForCallbacks(from: service, blog: blog) + + #expect(callbacks.count == 1) + guard case .failure = callbacks.first else { + Issue.record("Expected syncBlog to invoke its failure callback, got \(callbacks)") + return + } + #expect(blog.isXMLRPCDisabled) + } + + @Test @MainActor + func coreRESTBlogReportsMalformedOptionsResponse() async throws { + let host = "\(UUID().uuidString.lowercased()).example.com" + let coreDataStack = ContextManager.forTesting() + let blog = makeSelfHostedBlog(in: coreDataStack, host: host, usesCoreREST: true) + try blog.setApplicationToken("application-password") + defer { try? blog.deleteApplicationToken() } + coreDataStack.saveContextAndWait(coreDataStack.mainContext) + + let stubDescriptor = stub(condition: isHost(host) && isPath("/xmlrpc.php")) { _ in + HTTPStubsResponse( + data: Self.malformedOptionsResponse, + statusCode: 200, + headers: ["Content-Type": "text/xml; charset=UTF-8"] + ) + } + defer { HTTPStubs.removeStub(stubDescriptor) } + + let service = BlogService(coreDataStack: coreDataStack) + let callbacks = await waitForCallbacks(from: service, blog: blog) + + #expect(callbacks.count == 1) + guard case .failure(let error) = callbacks.first else { + Issue.record("Expected syncBlog to reject malformed options, got \(callbacks)") + return + } + #expect(error as? WordPressOrgXMLRPCApiError == .responseSerializationFailed) + #expect(!blog.isXMLRPCDisabled) + } + + @Test @MainActor + func coreRESTBlogReportsMissingPassword() async { + let host = "\(UUID().uuidString.lowercased()).example.com" + let coreDataStack = ContextManager.forTesting() + let blog = makeSelfHostedBlog(in: coreDataStack, host: host, usesCoreREST: true) + coreDataStack.saveContextAndWait(coreDataStack.mainContext) + + let service = BlogService(coreDataStack: coreDataStack) + let callbacks = await waitForCallbacks(from: service, blog: blog) + + #expect(callbacks.count == 1) + guard case .failure(let error) = callbacks.first, + let credentialsError = error as? Blog.BlogCredentialsError + else { + Issue.record("Expected syncBlog to report a credential error, got \(callbacks)") + return + } + guard case .blogPasswordMissing = credentialsError else { + Issue.record("Expected a missing password error, got \(credentialsError)") + return + } + #expect(!blog.isXMLRPCDisabled) + } + + @Test @MainActor + func legacyBlogStillSyncsOptionsUsingPassword() async { + let host = "\(UUID().uuidString.lowercased()).example.com" + let coreDataStack = ContextManager.forTesting() + let blog = makeSelfHostedBlog(in: coreDataStack, host: host, usesCoreREST: false) + blog.password = "legacy-password" + coreDataStack.saveContextAndWait(coreDataStack.mainContext) + + let stubDescriptor = stub(condition: isHost(host) && isPath("/xmlrpc.php")) { request in + let body = request.httpBodyStream?.read().flatMap { String(data: $0, encoding: .utf8) } + #expect(body?.contains("legacy-password") == true) + return HTTPStubsResponse( + data: Self.optionsResponse, + statusCode: 200, + headers: ["Content-Type": "text/xml; charset=UTF-8"] + ) + } + defer { HTTPStubs.removeStub(stubDescriptor) } + + let service = BlogService(coreDataStack: coreDataStack) + let callbacks = await waitForCallbacks(from: service, blog: blog) + + #expect(callbacks.count == 1) + guard case .success = callbacks.first else { + Issue.record("Expected legacy syncBlog to invoke success, got \(callbacks)") + return + } + #expect(blog.getOptionString(name: "jetpack_version") == "15.0") + } + + @Test @MainActor + func wordPressComBlogStillUsesWordPressComREST() async { + let coreDataStack = ContextManager.forTesting() + let blog = BlogBuilder(coreDataStack.mainContext, dotComID: 123) + .withAnAccount(authToken: "wpcom-token") + .isHostedAtWPcom() + .build() + coreDataStack.saveContextAndWait(coreDataStack.mainContext) + let requestRecorder = RequestRecorder() + + let stubDescriptor = stub( + condition: isHost("public-api.wordpress.com") && isPath("/rest/v1.1/sites/123") + ) { request in + requestRecorder.record(request.url) + return HTTPStubsResponse(error: URLError(.timedOut)) + } + defer { HTTPStubs.removeStub(stubDescriptor) } + + let service = BlogService(coreDataStack: coreDataStack) + let callbacks = await waitForCallbacks(from: service, blog: blog) + + #expect(callbacks.count == 1) + guard case .failure = callbacks.first else { + Issue.record("Expected the stubbed WordPress.com request to fail, got \(callbacks)") + return + } + #expect(requestRecorder.url?.host == "public-api.wordpress.com") + #expect(requestRecorder.url?.path == "/rest/v1.1/sites/123") + } + + @MainActor + private func makeSelfHostedBlog( + in coreDataStack: ContextManager, + host: String, + usesCoreREST: Bool + ) -> Blog { + let builder = BlogBuilder(coreDataStack.mainContext, dotComID: nil) + .with(url: "https://\(host)") + .with(username: "test-user") + .with(siteName: "Test Site") + if usesCoreREST { + _ = builder.with(restApiRootURL: "https://\(host)/wp-json") + } + let blog = builder.build() + blog.xmlrpc = "https://\(host)/xmlrpc.php" + return blog + } + + @MainActor + private func waitForCallbacks(from service: BlogService, blog: Blog) async -> [Callback] { + var callbacks: [Callback] = [] + service.syncBlog( + blog, + success: { + callbacks.append(.success) + }, + failure: { error in + callbacks.append(.failure(error)) + } + ) + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(5)) + while callbacks.isEmpty, clock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + if !callbacks.isEmpty { + try? await Task.sleep(for: .milliseconds(100)) + } + return callbacks + } + + private enum Callback: CustomStringConvertible { + case success + case failure(Error) + + var description: String { + switch self { + case .success: + return "success" + case .failure(let error): + return "failure(\(error))" + } + } + } + + private static let optionsResponse = Data( + """ + + + + + + + + jetpack_version + + + + value + 15.0 + + + + + + + + + + """ + .utf8 + ) + + private static let malformedOptionsResponse = Data( + """ + + + + unexpected + + + """ + .utf8 + ) +} + +private final class RequestRecorder { + private let lock = NSLock() + private var recordedURL: URL? + + var url: URL? { + lock.withLock { recordedURL } + } + + func record(_ url: URL?) { + lock.withLock { recordedURL = url } + } +} diff --git a/WordPress/Classes/Services/BlogService+Swift.swift b/WordPress/Classes/Services/BlogService+Swift.swift index da2b1dcd7f6e..e9c011546044 100644 --- a/WordPress/Classes/Services/BlogService+Swift.swift +++ b/WordPress/Classes/Services/BlogService+Swift.swift @@ -124,21 +124,37 @@ extension BlogService { @objc func syncXMLRPCOptionsIfApplicable( for blog: Blog, optionsHandler: @escaping (_ options: NSDictionary) -> Void, - failure: @escaping () -> Void + failure: @escaping (Error) -> Void ) { let blogObjectID = blog.objectID - guard blog.isSelfHosted, - let xmlrpcApi = blog.xmlrpcApi, - let username = blog.username, - let password = blog.password else { - - // Set isXMLRPCDisabled to false if the site is not a self-hosted site. - self.coreDataStack.performAndSave({ context in - guard let blog = try? context.existingObject(with: blogObjectID) as? Blog else { return } - blog.isXMLRPCDisabled = false - }, completion: failure, on: .main) + func failWithoutRequest(_ error: Error) { + self.coreDataStack.performAndSave( + { context in + guard let blog = try? context.existingObject(with: blogObjectID) as? Blog else { return } + blog.isXMLRPCDisabled = false + }, + completion: { + failure(error) + }, + on: .main + ) + } + guard blog.isSelfHosted else { + failWithoutRequest(Blog.BlogCredentialsError.invalidCredentialsUrl) + return + } + guard let xmlrpcApi = blog.xmlrpcApi else { + failWithoutRequest(Blog.BlogCredentialsError.invalidXmlRpcEndpoint) + return + } + guard let username = blog.username else { + failWithoutRequest(Blog.BlogCredentialsError.blogUsernameMissing) + return + } + guard let password = blog.password ?? (try? blog.getApplicationToken()) else { + failWithoutRequest(Blog.BlogCredentialsError.blogPasswordMissing) return } @@ -147,26 +163,30 @@ extension BlogService { Task { @MainActor in let result = await xmlrpcApi.call(method: "wp.getOptions", parameters: parameters) - self.coreDataStack.performAndSave { context in - guard let blog = try? context.existingObject(with: blogObjectID) as? Blog else { return } - if case let .failure(error) = result { - blog.isXMLRPCDisabled = error.xmlrpcAvailability == .unavailable - } else { - blog.isXMLRPCDisabled = false - } - } - - switch result { - case .success(let response): - if let options = response.body as? NSDictionary { - optionsHandler(options) - } else { - failure() - } - case .failure(let error): - DDLogError("Failed syncing XML-RPC options for blog: \(error)") - failure() - } + self.coreDataStack.performAndSave( + { context in + guard let blog = try? context.existingObject(with: blogObjectID) as? Blog else { return } + if case let .failure(error) = result { + blog.isXMLRPCDisabled = error.xmlrpcAvailability == .unavailable + } else { + blog.isXMLRPCDisabled = false + } + }, + completion: { + switch result { + case .success(let response): + if let options = response.body as? NSDictionary { + optionsHandler(options) + } else { + failure(WordPressOrgXMLRPCApiError.responseSerializationFailed) + } + case .failure(let error): + DDLogError("Failed syncing XML-RPC options for blog: \(error)") + failure(error) + } + }, + on: .main + ) } } diff --git a/WordPress/Classes/Services/BlogService.m b/WordPress/Classes/Services/BlogService.m index 323d96c64d27..c6fb77b899fa 100644 --- a/WordPress/Classes/Services/BlogService.m +++ b/WordPress/Classes/Services/BlogService.m @@ -56,18 +56,21 @@ - (void)syncBlog:(Blog *)blog success:(void (^)(void))success failure:(void (^)(NSError *error))failure { - id remote = [self remoteForBlog:blog]; - if ([remote isKindOfClass:[BlogServiceRemoteXMLRPC class]]) { - BlogServiceRemoteXMLRPC *xmlrpcRemote = remote; - [xmlrpcRemote syncBlogOptionsWithSuccess:[self optionsHandlerWithBlogObjectID:blog.objectID - completionHandler:success] - failure:failure]; - } else if ([remote isKindOfClass:[BlogServiceRemoteREST class]]) { - BlogServiceRemoteREST *restRemote = remote; - [restRemote syncBlogWithSuccess:[self blogDetailsHandlerWithBlogObjectID:blog.objectID - completionHandler:success] - failure:failure]; + if ([blog supports:BlogFeatureWpComRESTAPI]) { + id remote = [self remoteForBlog:blog]; + if ([remote isKindOfClass:[BlogServiceRemoteREST class]]) { + BlogServiceRemoteREST *restRemote = remote; + [restRemote syncBlogWithSuccess:[self blogDetailsHandlerWithBlogObjectID:blog.objectID + completionHandler:success] + failure:failure]; + return; + } } + + [self syncXMLRPCOptionsIfApplicableFor:blog + optionsHandler:[self optionsHandlerWithBlogObjectID:blog.objectID + completionHandler:success] + failure:failure]; } - (void)syncBlogAndAllMetadata:(Blog *)blog completionHandler:(void (^)(void))completionHandler @@ -108,7 +111,7 @@ - (void)syncBlogAndAllMetadata:(Blog *)blog completionHandler:(void (^)(void))co completionHandler:^{ dispatch_group_leave(syncGroup); }]; [self syncXMLRPCOptionsIfApplicableFor:blog optionsHandler:handler - failure:^{ dispatch_group_leave(syncGroup); }]; + failure:^(NSError * __unused error) { dispatch_group_leave(syncGroup); }]; } dispatch_group_enter(syncGroup);