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);