From a7795c47e7790ecb26d58680eba579653c1ad5ca Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 12 Aug 2026 10:41:58 +1200 Subject: [PATCH 1/3] Apply swift-format to account and blog services --- .../Services/AccountService+Swift.swift | 3 +- .../Classes/Services/BlogService+Swift.swift | 31 ++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/WordPress/Classes/Services/AccountService+Swift.swift b/WordPress/Classes/Services/AccountService+Swift.swift index 57c9356f7672..ac8e0024f479 100644 --- a/WordPress/Classes/Services/AccountService+Swift.swift +++ b/WordPress/Classes/Services/AccountService+Swift.swift @@ -16,7 +16,8 @@ extension AccountService { return } - UserPersistentStoreFactory.instance().set(account.uuid, forKey: AccountService.defaultDotcomAccountUUIDDefaultsKey) + UserPersistentStoreFactory.instance() + .set(account.uuid, forKey: AccountService.defaultDotcomAccountUUIDDefaultsKey) let objectID = TaggedManagedObjectID(account) let notifyAccountChange = { diff --git a/WordPress/Classes/Services/BlogService+Swift.swift b/WordPress/Classes/Services/BlogService+Swift.swift index e9c011546044..31158b67267b 100644 --- a/WordPress/Classes/Services/BlogService+Swift.swift +++ b/WordPress/Classes/Services/BlogService+Swift.swift @@ -20,11 +20,12 @@ extension BlogService { @objc public func updatePromptSettings(for blog: RemoteBlog?, context: NSManagedObjectContext) { guard let blog, - let jsonSettings = blog.options["blogging_prompts_settings"] as? [String: Any], - let settingsValue = jsonSettings["value"] as? [String: Any], - JSONSerialization.isValidJSONObject(settingsValue), - let data = try? JSONSerialization.data(withJSONObject: settingsValue), - let remoteSettings = try? JSONDecoder().decode(RemoteBloggingPromptsSettings.self, from: data) else { + let jsonSettings = blog.options["blogging_prompts_settings"] as? [String: Any], + let settingsValue = jsonSettings["value"] as? [String: Any], + JSONSerialization.isValidJSONObject(settingsValue), + let data = try? JSONSerialization.data(withJSONObject: settingsValue), + let remoteSettings = try? JSONDecoder().decode(RemoteBloggingPromptsSettings.self, from: data) + else { return } @@ -190,7 +191,10 @@ extension BlogService { } } - static func blog(with site: JetpackSiteRef, context: NSManagedObjectContext = ContextManager.shared.mainContext) -> Blog? { + static func blog( + with site: JetpackSiteRef, + context: NSManagedObjectContext = ContextManager.shared.mainContext + ) -> Blog? { let blog: Blog? if site.isSelfHostedWithoutJetpack, let xmlRPC = site.xmlRPC { @@ -204,7 +208,18 @@ extension BlogService { } private extension BlogService { - private func findBlogAuthor(with userId: NSNumber, and blog: Blog, in context: NSManagedObjectContext) -> BlogAuthor { - return context.entity(of: BlogAuthor.self, with: NSPredicate(format: "\(#keyPath(BlogAuthor.userID)) = %@ AND \(#keyPath(BlogAuthor.blog)) = %@", userId, blog)) + private func findBlogAuthor( + with userId: NSNumber, + and blog: Blog, + in context: NSManagedObjectContext + ) -> BlogAuthor { + context.entity( + of: BlogAuthor.self, + with: NSPredicate( + format: "\(#keyPath(BlogAuthor.userID)) = %@ AND \(#keyPath(BlogAuthor.blog)) = %@", + userId, + blog + ) + ) } } From 0be953aad8c482d6e245875d83686504c4be77b8 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 30 Jul 2026 10:19:33 +1200 Subject: [PATCH 2/3] Evict WordPress clients during account and site removal --- .../WordPressClientFactoryTests.swift | 90 +++++++++++++++++++ .../Classes/Networking/WordPressClient.swift | 6 ++ .../Services/AccountService+Swift.swift | 5 ++ .../Classes/Services/BlogService+Swift.swift | 5 ++ WordPress/Classes/Services/BlogService.m | 1 + 5 files changed, 107 insertions(+) create mode 100644 Tests/KeystoneTests/Tests/Networking/WordPressClientFactoryTests.swift diff --git a/Tests/KeystoneTests/Tests/Networking/WordPressClientFactoryTests.swift b/Tests/KeystoneTests/Tests/Networking/WordPressClientFactoryTests.swift new file mode 100644 index 000000000000..d32fa46b3aab --- /dev/null +++ b/Tests/KeystoneTests/Tests/Networking/WordPressClientFactoryTests.swift @@ -0,0 +1,90 @@ +import OHHTTPStubs +import OHHTTPStubsSwift +import XCTest + +@testable import WordPress +@testable import WordPressData + +final class WordPressClientFactoryTests: CoreDataTestCase { + override func setUp() { + super.setUp() + contextManager.useAsSharedInstance(untilTestFinished: self) + WordPressClientFactory.shared.reset() + HTTPStubs.removeAllStubs() + stub(condition: { _ in true }) { _ in + HTTPStubsResponse( + jsonObject: [:], + statusCode: 200, + headers: ["Content-Type": "application/json"] + ) + } + } + + override func tearDown() { + WordPressClientFactory.shared.reset() + HTTPStubs.removeAllStubs() + super.tearDown() + } + + func testEvictInstanceIsIdempotentAndCreatesANewClient() throws { + let site = try makeSite(dotComID: 123) + let otherSite = try makeSite(dotComID: 456) + let original = WordPressClientFactory.shared.instance(for: site) + let otherOriginal = WordPressClientFactory.shared.instance(for: otherSite) + + WordPressClientFactory.shared.evictInstance(for: site.blogId) + WordPressClientFactory.shared.evictInstance(for: site.blogId) + + let replacement = WordPressClientFactory.shared.instance(for: site) + XCTAssertFalse(original === replacement) + XCTAssertTrue(replacement === WordPressClientFactory.shared.instance(for: site)) + XCTAssertTrue(otherOriginal === WordPressClientFactory.shared.instance(for: otherSite)) + } + + func testResetCreatesANewClient() throws { + let site = try makeSite(dotComID: 123) + let original = WordPressClientFactory.shared.instance(for: site) + + WordPressClientFactory.shared.reset() + + XCTAssertFalse(original === WordPressClientFactory.shared.instance(for: site)) + } + + func testRemovingDefaultAccountEvictsBlogClient() throws { + let blog = makeBlog(dotComID: 123) + try mainContext.save() + let site = try WordPressSite(blog: blog) + let original = WordPressClientFactory.shared.instance(for: site) + let service = AccountService(coreDataStack: contextManager) + service.setDefaultWordPressComAccount(try XCTUnwrap(blog.account)) + + service.removeDefaultWordPressComAccount() + + XCTAssertFalse(original === WordPressClientFactory.shared.instance(for: site)) + } + + func testRemovingBlogEvictsClient() throws { + let blog = makeBlog(dotComID: 123) + try mainContext.save() + let site = try WordPressSite(blog: blog) + let original = WordPressClientFactory.shared.instance(for: site) + + BlogService(coreDataStack: contextManager).remove(blog) + + XCTAssertFalse(original === WordPressClientFactory.shared.instance(for: site)) + } + + private func makeSite(dotComID: Int) throws -> WordPressSite { + try WordPressSite(blog: makeBlog(dotComID: dotComID)) + } + + private func makeBlog(dotComID: Int) -> Blog { + let blog = BlogBuilder(mainContext, dotComID: NSNumber(value: dotComID)) + .with(url: "https://example.wordpress.com") + .isHostedAtWPcom() + .withAnAccount(username: "test-user", authToken: "test-token") + .build() + blog.account?.uuid = UUID().uuidString + return blog + } +} diff --git a/WordPress/Classes/Networking/WordPressClient.swift b/WordPress/Classes/Networking/WordPressClient.swift index 4cb9ca5e28fe..55fa51468780 100644 --- a/WordPress/Classes/Networking/WordPressClient.swift +++ b/WordPress/Classes/Networking/WordPressClient.swift @@ -25,6 +25,12 @@ public final class WordPressClientFactory: Sendable { } } + public func evictInstance(for blogID: TaggedManagedObjectID) { + instances.withLock { instances in + instances.removeValue(forKey: blogID) + } + } + public func reset() { instances.withLock { dict in dict.removeAll() diff --git a/WordPress/Classes/Services/AccountService+Swift.swift b/WordPress/Classes/Services/AccountService+Swift.swift index ac8e0024f479..1a84e2ee5e86 100644 --- a/WordPress/Classes/Services/AccountService+Swift.swift +++ b/WordPress/Classes/Services/AccountService+Swift.swift @@ -49,6 +49,11 @@ extension AccountService { return } + account.blogs? + .forEach { + WordPressClientFactory.shared.evictInstance(for: TaggedManagedObjectID($0)) + } + let objectID = TaggedManagedObjectID(account) coreDataStack.performAndSave { context in do { diff --git a/WordPress/Classes/Services/BlogService+Swift.swift b/WordPress/Classes/Services/BlogService+Swift.swift index 31158b67267b..45d5749aa0ce 100644 --- a/WordPress/Classes/Services/BlogService+Swift.swift +++ b/WordPress/Classes/Services/BlogService+Swift.swift @@ -6,6 +6,11 @@ import WordPressCore import WordPressAPI extension BlogService { + @objc(evictWordPressClientForBlog:) + public func evictWordPressClient(for blog: Blog) { + WordPressClientFactory.shared.evictInstance(for: TaggedManagedObjectID(blog)) + } + @objc public func unscheduleBloggingReminders(for blog: Blog) { do { let scheduler = try ReminderScheduleCoordinator() diff --git a/WordPress/Classes/Services/BlogService.m b/WordPress/Classes/Services/BlogService.m index ae84350e8aa9..5b30435bea10 100644 --- a/WordPress/Classes/Services/BlogService.m +++ b/WordPress/Classes/Services/BlogService.m @@ -306,6 +306,7 @@ - (void)removeBlog:(Blog *)blog DDLogInfo(@" remove", blog.displayURL); [blog.xmlrpcApi invalidateAndCancelTasks]; [self unscheduleBloggingRemindersFor:blog]; + [self evictWordPressClientForBlog:blog]; WPAccount *account = blog.account; From d0f2aba65fcbdeeb5a622191e88ad33811429213 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 12 Aug 2026 10:33:30 +1200 Subject: [PATCH 3/3] Evict WordPress clients when sync removes sites --- .../Tests/Services/BlogServiceTest.m | 23 +++++++++++++++++++ WordPress/Classes/Services/BlogService.m | 1 + 2 files changed, 24 insertions(+) diff --git a/Tests/KeystoneTests/Tests/Services/BlogServiceTest.m b/Tests/KeystoneTests/Tests/Services/BlogServiceTest.m index 93c9c86769bb..dc473fbf557b 100644 --- a/Tests/KeystoneTests/Tests/Services/BlogServiceTest.m +++ b/Tests/KeystoneTests/Tests/Services/BlogServiceTest.m @@ -8,6 +8,14 @@ @import OCMock; +@interface BlogService (Testing) + +- (void)mergeBlogs:(NSArray *)blogs + withAccountID:(NSManagedObjectID *)accountID + inContext:(NSManagedObjectContext *)context; + +@end + @interface BlogServiceTest : XCTestCase @property (nonatomic, strong) BlogService *blogService; @@ -69,6 +77,21 @@ - (void)cleanUpNSUserDefaultValues [UserSettings setDefaultDotComUUID:nil]; } +- (void)testMergeBlogsEvictsClientForDeletedBlog +{ + self.blog.dotComID = @1; + WPAccount *account = self.blog.account; + + OCMExpect([self.blogServiceMock evictWordPressClientForBlog:self.blog]); + + [self.blogServiceMock mergeBlogs:@[] + withAccountID:account.objectID + inContext:self.coreDataStack.mainContext]; + + OCMVerifyAll(self.blogServiceMock); + XCTAssertTrue(self.blog.isDeleted); +} + - (void)testUpdateSettingsAppliesPresentValuesIncludingFalse { self.blog.settings.commentsAllowed = @YES; diff --git a/WordPress/Classes/Services/BlogService.m b/WordPress/Classes/Services/BlogService.m index 5b30435bea10..db7a0e10426f 100644 --- a/WordPress/Classes/Services/BlogService.m +++ b/WordPress/Classes/Services/BlogService.m @@ -374,6 +374,7 @@ - (void)mergeBlogs:(NSArray *)blogs withAccountID:(NSManagedObject for (Blog *blog in account.blogs) { if ([toDelete containsObject:blog.dotComID]) { [self unscheduleBloggingRemindersFor:blog]; + [self evictWordPressClientForBlog:blog]; // Consider switching this to a call to removeBlog in the future // to consolidate behaviour @frosty [context deleteObject:blog];