From 5437a4d4491d3a6f40f34dd4d08ef60e9c197498 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 29 Jan 2026 11:43:01 +1300 Subject: [PATCH 1/4] Add an integration test to reproduce a collection update issue --- .../PostCollectionTests.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 native/swift/Tests/integration-tests/PostCollectionTests.swift diff --git a/native/swift/Tests/integration-tests/PostCollectionTests.swift b/native/swift/Tests/integration-tests/PostCollectionTests.swift new file mode 100644 index 000000000..f4a32e8f1 --- /dev/null +++ b/native/swift/Tests/integration-tests/PostCollectionTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@preconcurrency import Combine + +@testable import WordPressAPI +@testable import WordPressApiCache +import WordPressAPIInternal + +struct PostCollectionTests { + let api = WordPressAPI.admin() + + /// Verifies that refreshing one post collection does not trigger updates on unrelated collections. + /// + /// Given two unrelated post collections—one for draft posts and one for published posts—this test + /// ensures that refreshing one collection does not cause updates on the other collection. + @Test + func updateShouldBeIsolated() async throws { + let (cache, service) = try testContext() + + let draftCollection = service + .posts() + .createPostMetadataCollectionWithEditContext( + endpointType: .posts, + filter: .init(status: [.draft]), + perPage: 10 + ) + let draftCollectionUpdates: Task<[UpdateHook], Never> = Task { + await cache.databaseUpdatesPublisher() + .filter { [draftCollection] in draftCollection.isRelevantUpdate(hook: $0) } + .timeout(1, scheduler: DispatchQueue.main) + .values + .reduce(into: []) { $0.append($1) } + } + + let publishCollection = service + .posts() + .createPostMetadataCollectionWithEditContext( + endpointType: .posts, + filter: .init(status: [.publish]), + perPage: 10 + ) + + _ = try await publishCollection.refresh() + + await #expect(draftCollectionUpdates.value.count == 0) + } + + private func testContext() throws -> (WordPressApiCache, WpSelfHostedService) { + let cache: WordPressApiCache = try WordPressApiCache() + _ = try cache.performMigrations() + cache.startListeningForUpdates() + + return try (cache, api.createSelfHostedService(cache: cache)) + } +} From 30ad7aefae1e8f02677eb0a4bc4f9fc910afe2b5 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 29 Jan 2026 11:47:59 +1300 Subject: [PATCH 2/4] Add another tests --- .../PostCollectionTests.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/native/swift/Tests/integration-tests/PostCollectionTests.swift b/native/swift/Tests/integration-tests/PostCollectionTests.swift index f4a32e8f1..08db6ec82 100644 --- a/native/swift/Tests/integration-tests/PostCollectionTests.swift +++ b/native/swift/Tests/integration-tests/PostCollectionTests.swift @@ -45,6 +45,31 @@ struct PostCollectionTests { await #expect(draftCollectionUpdates.value.count == 0) } + @Test + func minimalUpdates() async throws { + let (cache, service) = try testContext() + + let collection = service + .posts() + .createPostMetadataCollectionWithEditContext( + endpointType: .posts, + filter: .init(status: [.draft]), + perPage: 10 + ) + let updates: Task<[UpdateHook], Never> = Task { + await cache.databaseUpdatesPublisher() + .filter { [collection] in collection.isRelevantUpdate(hook: $0) } + .timeout(1, scheduler: DispatchQueue.main) + .values + .reduce(into: []) { $0.append($1) } + } + + _ = try await collection.refresh() + + // TODO: What's the reasonable amount of updates for the `refresh` call? + await #expect(updates.value.count < 5) + } + private func testContext() throws -> (WordPressApiCache, WpSelfHostedService) { let cache: WordPressApiCache = try WordPressApiCache() _ = try cache.performMigrations() From 6a606a7f04369620213a8dbc737227dfea5fd030 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 29 Jan 2026 11:55:25 +1300 Subject: [PATCH 3/4] Update code comments --- .../swift/Tests/integration-tests/PostCollectionTests.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/native/swift/Tests/integration-tests/PostCollectionTests.swift b/native/swift/Tests/integration-tests/PostCollectionTests.swift index 08db6ec82..d7e74bce0 100644 --- a/native/swift/Tests/integration-tests/PostCollectionTests.swift +++ b/native/swift/Tests/integration-tests/PostCollectionTests.swift @@ -9,10 +9,7 @@ import WordPressAPIInternal struct PostCollectionTests { let api = WordPressAPI.admin() - /// Verifies that refreshing one post collection does not trigger updates on unrelated collections. - /// - /// Given two unrelated post collections—one for draft posts and one for published posts—this test - /// ensures that refreshing one collection does not cause updates on the other collection. + /// Reproduces an issue where refreshing one post collection trigger updates on an unrelated collection. @Test func updateShouldBeIsolated() async throws { let (cache, service) = try testContext() @@ -45,6 +42,7 @@ struct PostCollectionTests { await #expect(draftCollectionUpdates.value.count == 0) } + /// Reproduces an issue where refreshing a post collection sends way too many updates. @Test func minimalUpdates() async throws { let (cache, service) = try testContext() From 3e0c0f7d499427e823690eb4e8ff9e9437e35002 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 30 Jan 2026 10:55:53 +1300 Subject: [PATCH 4/4] Port integration tests to Rust --- .../PostCollectionTests.swift | 78 ------------ .../tests/test_collection_updates.rs | 116 ++++++++++++++++++ 2 files changed, 116 insertions(+), 78 deletions(-) delete mode 100644 native/swift/Tests/integration-tests/PostCollectionTests.swift create mode 100644 wp_mobile_integration_tests/tests/test_collection_updates.rs diff --git a/native/swift/Tests/integration-tests/PostCollectionTests.swift b/native/swift/Tests/integration-tests/PostCollectionTests.swift deleted file mode 100644 index d7e74bce0..000000000 --- a/native/swift/Tests/integration-tests/PostCollectionTests.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation -import Testing -@preconcurrency import Combine - -@testable import WordPressAPI -@testable import WordPressApiCache -import WordPressAPIInternal - -struct PostCollectionTests { - let api = WordPressAPI.admin() - - /// Reproduces an issue where refreshing one post collection trigger updates on an unrelated collection. - @Test - func updateShouldBeIsolated() async throws { - let (cache, service) = try testContext() - - let draftCollection = service - .posts() - .createPostMetadataCollectionWithEditContext( - endpointType: .posts, - filter: .init(status: [.draft]), - perPage: 10 - ) - let draftCollectionUpdates: Task<[UpdateHook], Never> = Task { - await cache.databaseUpdatesPublisher() - .filter { [draftCollection] in draftCollection.isRelevantUpdate(hook: $0) } - .timeout(1, scheduler: DispatchQueue.main) - .values - .reduce(into: []) { $0.append($1) } - } - - let publishCollection = service - .posts() - .createPostMetadataCollectionWithEditContext( - endpointType: .posts, - filter: .init(status: [.publish]), - perPage: 10 - ) - - _ = try await publishCollection.refresh() - - await #expect(draftCollectionUpdates.value.count == 0) - } - - /// Reproduces an issue where refreshing a post collection sends way too many updates. - @Test - func minimalUpdates() async throws { - let (cache, service) = try testContext() - - let collection = service - .posts() - .createPostMetadataCollectionWithEditContext( - endpointType: .posts, - filter: .init(status: [.draft]), - perPage: 10 - ) - let updates: Task<[UpdateHook], Never> = Task { - await cache.databaseUpdatesPublisher() - .filter { [collection] in collection.isRelevantUpdate(hook: $0) } - .timeout(1, scheduler: DispatchQueue.main) - .values - .reduce(into: []) { $0.append($1) } - } - - _ = try await collection.refresh() - - // TODO: What's the reasonable amount of updates for the `refresh` call? - await #expect(updates.value.count < 5) - } - - private func testContext() throws -> (WordPressApiCache, WpSelfHostedService) { - let cache: WordPressApiCache = try WordPressApiCache() - _ = try cache.performMigrations() - cache.startListeningForUpdates() - - return try (cache, api.createSelfHostedService(cache: cache)) - } -} diff --git a/wp_mobile_integration_tests/tests/test_collection_updates.rs b/wp_mobile_integration_tests/tests/test_collection_updates.rs new file mode 100644 index 000000000..cdb108eda --- /dev/null +++ b/wp_mobile_integration_tests/tests/test_collection_updates.rs @@ -0,0 +1,116 @@ +use std::sync::{Arc, Mutex}; +use wp_api::posts::PostStatus; +use wp_api::request::endpoint::posts_endpoint::PostEndpointType; +use wp_mobile::filters::PostListFilter; +use wp_mobile_cache::{DatabaseDelegate, UpdateHook}; +use wp_mobile_integration_tests::*; + +/// Reproduces an issue where refreshing a post collection sends way too many updates. +#[tokio::test] +#[parallel] +async fn test_minimal_updates() { + let ctx = create_test_context(); + + let collection = ctx + .service + .posts() + .create_post_metadata_collection_with_edit_context( + PostEndpointType::Posts, + PostListFilter { + status: vec![PostStatus::Draft], + ..Default::default() + }, + 10, + ); + + let collector = Arc::new(UpdateCollector::new()); + ctx.cache.start_listening_for_updates(collector.clone()); + + let result = collection.refresh().await; + assert!(result.is_ok(), "refresh should succeed: {:?}", result.err()); + + let all_updates = collector.collected_updates(); + let relevant_updates: Vec<_> = all_updates + .iter() + .filter(|hook| collection.is_relevant_update(hook)) + .collect(); + + // TODO: What's the reasonable amount of updates for the `refresh` call? + assert!( + relevant_updates.len() < 5, + "expected fewer than 5 relevant updates, got {}", + relevant_updates.len() + ); +} + +/// Reproduces an issue where refreshing one post collection trigger updates on an unrelated collection. +#[tokio::test] +#[parallel] +async fn test_update_should_be_isolated() { + let ctx = create_test_context(); + + let draft_collection = ctx + .service + .posts() + .create_post_metadata_collection_with_edit_context( + PostEndpointType::Posts, + PostListFilter { + status: vec![PostStatus::Draft], + ..Default::default() + }, + 10, + ); + + let collector = Arc::new(UpdateCollector::new()); + ctx.cache.start_listening_for_updates(collector.clone()); + + let publish_collection = ctx + .service + .posts() + .create_post_metadata_collection_with_edit_context( + PostEndpointType::Posts, + PostListFilter { + status: vec![PostStatus::Publish], + ..Default::default() + }, + 10, + ); + + let result = publish_collection.refresh().await; + assert!(result.is_ok(), "refresh should succeed: {:?}", result.err()); + + let all_updates = collector.collected_updates(); + let draft_relevant_updates: Vec<_> = all_updates + .iter() + .filter(|hook| draft_collection.is_relevant_update(hook)) + .collect(); + + assert_eq!( + draft_relevant_updates.len(), + 0, + "expected no updates relevant to draft collection when refreshing publish collection, got {}", + draft_relevant_updates.len() + ); +} + +struct UpdateCollector { + updates: Mutex>, +} + +impl UpdateCollector { + fn new() -> Self { + Self { + updates: Mutex::new(Vec::new()), + } + } + + fn collected_updates(&self) -> Vec { + self.updates.lock().unwrap().clone() + } +} + +impl DatabaseDelegate for UpdateCollector { + fn did_update(&self, update_hook: UpdateHook) { + self.updates.lock().unwrap().push(update_hook); + } +}