From 0a50b8bcb2ac62ea642bcdefe268154485e6f1da Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Wed, 22 Jan 2025 21:58:31 +0100 Subject: [PATCH 1/8] OF-2437: Introduce CachingPubsubPersistenceProviderTest These new unit tests verify behavior of CachingPubsubPersistenceProvider (which fails, as indicated by some of these test failing). The tests introduced here cover four types of operations that are being scheduled by the implementation: - Node creation/changing/removal - Subscription creation/update/removal - Affiliation creation/update/removal - publishing/removal of items --- .../CachingPubsubPersistenceProvider.java | 21 +- ...enceProviderAffiliationOperationsTest.java | 776 ++++++++++++++++++ ...PersistenceProviderItemOperationsTest.java | 339 ++++++++ ...PersistenceProviderNodeOperationsTest.java | 438 ++++++++++ ...nceProviderSubscriptionOperationsTest.java | 740 +++++++++++++++++ 5 files changed, 2308 insertions(+), 6 deletions(-) create mode 100644 xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java create mode 100644 xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java create mode 100644 xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderNodeOperationsTest.java create mode 100644 xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderSubscriptionOperationsTest.java diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProvider.java b/xmppserver/src/main/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProvider.java index 405e337f25..603ca59da3 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProvider.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProvider.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020-2022 Ignite Realtime Foundation. All rights reserved. + * Copyright (C) 2020-2025 Ignite Realtime Foundation. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ */ package org.jivesoftware.openfire.pubsub; +import com.google.common.annotations.VisibleForTesting; import org.jivesoftware.openfire.cluster.ClusterManager; import org.jivesoftware.openfire.pep.PEPService; import org.jivesoftware.openfire.pubsub.cluster.FlushTask; @@ -47,7 +48,11 @@ public class CachingPubsubPersistenceProvider implements PubSubPersistenceProvid .setDynamic(false) .build(); - private PubSubPersistenceProvider delegate; + /** + * The delegate instance, used by this instance to interact with persistent data storage. + */ + @VisibleForTesting + PubSubPersistenceProvider delegate; /** * Pseudo-random number generator is used to offset timing for scheduled tasks @@ -69,20 +74,24 @@ public class CachingPubsubPersistenceProvider implements PubSubPersistenceProvid /** * Queue that holds the (wrapped) items that need to be added to the database. */ - private Deque itemsToAdd = new ConcurrentLinkedDeque<>(); + @VisibleForTesting + Deque itemsToAdd = new ConcurrentLinkedDeque<>(); /** * Queue that holds the items that need to be deleted from the database. */ - private Deque itemsToDelete = new ConcurrentLinkedDeque<>(); + @VisibleForTesting + Deque itemsToDelete = new ConcurrentLinkedDeque<>(); /** * Keeps reference to published items that haven't been persisted yet so they * can be removed before being deleted. */ - private final HashMap itemsPending = new HashMap<>(); + @VisibleForTesting + final HashMap itemsPending = new HashMap<>(); - private ConcurrentMap> nodesToProcess = new ConcurrentHashMap<>(); + @VisibleForTesting + final ConcurrentMap> nodesToProcess = new ConcurrentHashMap<>(); /** * Cache name for recently accessed published items. diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java new file mode 100644 index 0000000000..4c1b432762 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java @@ -0,0 +1,776 @@ +/* + * Copyright (C) 2025 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.pubsub; + +import org.jivesoftware.util.StringUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.xmpp.packet.JID; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Verifies the implementation of {@link CachingPubsubPersistenceProvider} + * + * The unit tests in this class are limited to the operations that create, update and remove affiliations. + * + * @author Guus der Kinderen, guus@goodbytes.nl + */ +@ExtendWith(MockitoExtension.class) +public class CachingPubsubPersistenceProviderAffiliationOperationsTest +{ + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#createAffiliation(Node, NodeAffiliate)} + * causes a corresponding operation to be scheduled. + */ + @Test + public void testCreateAffiliation() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID affiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate affiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation affiliation = NodeAffiliate.Affiliation.publisher; + affiliate.setAffiliation(affiliation); + + // Execute system under test. + provider.createAffiliation(mockNode, affiliate); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_AFFILIATION, nodeOperation.action); + assertEquals(affiliateAddress, nodeOperation.affiliate.getJID()); + assertEquals(affiliation, nodeOperation.affiliate.getAffiliation()); + assertNull(nodeOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#updateAffiliation(Node, NodeAffiliate)} + * causes a corresponding operation to be scheduled. + */ + @Test + public void testUpdateAffiliation() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID affiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate affiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation affiliation = NodeAffiliate.Affiliation.publisher; + affiliate.setAffiliation(affiliation); + + // Execute system under test. + provider.updateAffiliation(mockNode, affiliate); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.UPDATE_AFFILIATION, nodeOperation.action); + assertEquals(affiliateAddress, nodeOperation.affiliate.getJID()); + assertEquals(affiliation, nodeOperation.affiliate.getAffiliation()); + assertNull(nodeOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#removeAffiliation(Node, NodeAffiliate)} causes a corresponding + * operation to be scheduled. + */ + @Test + public void testDeleteAffiliation() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID affiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate affiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation affiliation = NodeAffiliate.Affiliation.publisher; + affiliate.setAffiliation(affiliation); + + // Execute system under test. + provider.removeAffiliation(mockNode, affiliate); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE_AFFILIATION, nodeOperation.action); + assertEquals(affiliateAddress, nodeOperation.affiliate.getJID()); + assertEquals(affiliation, nodeOperation.affiliate.getAffiliation()); + assertNull(nodeOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createAffiliation(Node, NodeAffiliate)} + * attempts to create the same affiliation 'twice'. + * + * This tests an erroneous invocation. The design of the caching provider is such that it shouldn't try to 'clean up' + * such misbehavior. Instead, it should pass this through to the delegate, which is expected to generate an + * appropriate error response. + * + * Therefor, this test asserts that for each invocation, a corresponding operation is scheduled. + */ + @Test + public void testCreateAffiliationTwice() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID affiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate affiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation affiliation = NodeAffiliate.Affiliation.publisher; + affiliate.setAffiliation(affiliation); + + // Execute system under test. + provider.createAffiliation(mockNode, affiliate); + provider.createAffiliation(mockNode, affiliate); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstNodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), firstNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_AFFILIATION, firstNodeOperation.action); + assertEquals(affiliateAddress, firstNodeOperation.affiliate.getJID()); + assertEquals(affiliation, firstNodeOperation.affiliate.getAffiliation()); + assertNull(firstNodeOperation.subscription); + + final CachingPubsubPersistenceProvider.NodeOperation secondNodeOperation = pending.get(1); + assertEquals(mockNode.getUniqueIdentifier(), secondNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_AFFILIATION, secondNodeOperation.action); + assertEquals(affiliateAddress, secondNodeOperation.affiliate.getJID()); + assertEquals(affiliation, secondNodeOperation.affiliate.getAffiliation()); + assertNull(secondNodeOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createAffiliation(Node, NodeAffiliate)} + * attempts to create the multiple, different affiliations. + * + * This test asserts that for each invocation, a corresponding operation is scheduled. + */ + @Test + public void testCreateAffiliations() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID firstAffiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate firstAffiliate = new NodeAffiliate(mockNode, firstAffiliateAddress); + final NodeAffiliate.Affiliation firstAffiliation = NodeAffiliate.Affiliation.publisher; + firstAffiliate.setAffiliation(firstAffiliation); + final JID secondAffiliateAddress = new JID("test-user-2", "example.org", null); + final NodeAffiliate secondAffiliate = new NodeAffiliate(mockNode, secondAffiliateAddress); + final NodeAffiliate.Affiliation secondAffiliation = NodeAffiliate.Affiliation.outcast; + secondAffiliate.setAffiliation(secondAffiliation); + + // Execute system under test. + provider.createAffiliation(mockNode, firstAffiliate); + provider.createAffiliation(mockNode, secondAffiliate); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstNodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), firstNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_AFFILIATION, firstNodeOperation.action); + assertEquals(firstAffiliateAddress, firstNodeOperation.affiliate.getJID()); + assertEquals(firstAffiliation, firstNodeOperation.affiliate.getAffiliation()); + assertNull(firstNodeOperation.subscription); + + final CachingPubsubPersistenceProvider.NodeOperation secondNodeOperation = pending.get(1); + assertEquals(mockNode.getUniqueIdentifier(), secondNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_AFFILIATION, secondNodeOperation.action); + assertEquals(secondAffiliateAddress, secondNodeOperation.affiliate.getJID()); + assertEquals(secondAffiliation, secondNodeOperation.affiliate.getAffiliation()); + assertNull(secondNodeOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#updateAffiliation(Node, NodeAffiliate)} + * attempts to update the same affiliation (with the same user and same node) 'twice'. + * + * Updates of an affiliation are never partial. This allows the caching provider to optimize two sequential updates. + * + * Therefor, this test asserts that after each invocation, only one operation, corresponding to the last update, is + * scheduled. + */ + @Test + public void testUpdateAffiliationTwice() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID affiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate firstAffiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation firstAffiliation = NodeAffiliate.Affiliation.publisher; + firstAffiliate.setAffiliation(firstAffiliation); + final NodeAffiliate secondAffiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation secondAffiliation = NodeAffiliate.Affiliation.outcast; + secondAffiliate.setAffiliation(secondAffiliation); + + // Execute system under test. + provider.updateAffiliation(mockNode, firstAffiliate); + provider.updateAffiliation(mockNode, secondAffiliate); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.UPDATE_AFFILIATION, nodeOperation.action); + assertEquals(affiliateAddress, nodeOperation.affiliate.getJID()); + assertEquals(secondAffiliation, nodeOperation.affiliate.getAffiliation()); // must match that of the last invocation. + assertNull(nodeOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createAffiliation(Node, NodeAffiliate)} + * and {@link CachingPubsubPersistenceProvider#removeAffiliation(Node, NodeAffiliate)} creates and immediately removes + * an affiliation. + * + * The caching provider can optimize these two operations, where the 'net effect' of them is to have no operation. + * + * Therefor, this test asserts that after each invocation, no operation is scheduled. + */ + @Test + public void testDeleteAffiliationAfterCreateAffiliation() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID affiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate affiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation affiliation = NodeAffiliate.Affiliation.publisher; + affiliate.setAffiliation(affiliation); + + // Execute system under test. + provider.createAffiliation(mockNode, affiliate); + provider.removeAffiliation(mockNode, affiliate); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#removeAffiliation(Node, NodeAffiliate)} and + * {@link CachingPubsubPersistenceProvider#createAffiliation(Node, NodeAffiliate)} removes (an affiliation that + * is thought to preexist) and then recreate an affiliation again. + * + * This test asserts that after both invocations, two corresponding operation are scheduled in that order. + */ + @Test + public void testDeleteAffiliationDoesNotVoidNewerCreate() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID affiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate affiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation affiliation = NodeAffiliate.Affiliation.publisher; + affiliate.setAffiliation(affiliation); + + // Execute system under test. + provider.removeAffiliation(mockNode, affiliate); + provider.createAffiliation(mockNode, affiliate); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), firstOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE_AFFILIATION, firstOperation.action); + assertEquals(affiliateAddress, firstOperation.affiliate.getJID()); + assertEquals(affiliation, firstOperation.affiliate.getAffiliation()); + assertNull(firstOperation.subscription); + + final CachingPubsubPersistenceProvider.NodeOperation secondOperation = pending.get(1); + assertEquals(mockNode.getUniqueIdentifier(), secondOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_AFFILIATION, secondOperation.action); + assertEquals(affiliateAddress, secondOperation.affiliate.getJID()); + assertEquals(affiliation, secondOperation.affiliate.getAffiliation()); + assertNull(secondOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createAffiliation(Node, NodeAffiliate)}, + * {@link CachingPubsubPersistenceProvider#removeAffiliation(Node, NodeAffiliate)} and + * {@link CachingPubsubPersistenceProvider#createAffiliation(Node, NodeAffiliate)} + * creates, then removes and then recreate an affiliation again. + * + * The caching provider can optimize the first two operations, where the 'net effect' of them is to have no operation. + * + * Therefor, this test asserts that after all invocations, only one operation that represents the last invocation, + * is scheduled. + */ + @Test + public void testDeleteAffiliationDoesNotVoidNewerCreate2() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID affiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate affiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation affiliation = NodeAffiliate.Affiliation.publisher; + affiliate.setAffiliation(affiliation); + + // Execute system under test. + provider.createAffiliation(mockNode, affiliate); + provider.removeAffiliation(mockNode, affiliate); + provider.createAffiliation(mockNode, affiliate); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_AFFILIATION, nodeOperation.action); + assertEquals(affiliateAddress, nodeOperation.affiliate.getJID()); + assertEquals(affiliation, nodeOperation.affiliate.getAffiliation()); + assertNull(nodeOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#updateAffiliation(Node, NodeAffiliate)} + * and {@link CachingPubsubPersistenceProvider#removeAffiliation(Node, NodeAffiliate)} first updates (an affiliation + * that is thought to preexist) and then removes that affiliation. + * + * The caching provider can optimize the two operations, as the 'net effect' of them is to have removal. + * + * Therefor, this test asserts that after all invocations, only one operation that represents the last invocation, + * is scheduled. + */ + @Test + public void testDeleteAffiliationReplacesOlderUpdate() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID affiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate affiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation affiliation = NodeAffiliate.Affiliation.publisher; + affiliate.setAffiliation(affiliation); + + // Execute system under test. + provider.updateAffiliation(mockNode, affiliate); + provider.removeAffiliation(mockNode, affiliate); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE_AFFILIATION, nodeOperation.action); + assertEquals(affiliateAddress, nodeOperation.affiliate.getJID()); + assertEquals(affiliation, nodeOperation.affiliate.getAffiliation()); + assertNull(nodeOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createAffiliation(Node, NodeAffiliate)}, + * {@link CachingPubsubPersistenceProvider#updateAffiliation(Node, NodeAffiliate)} and + * {@link CachingPubsubPersistenceProvider#removeAffiliation(Node, NodeAffiliate)} an affiliation is created, updated + * and immediately removes again. + * + * The caching provider can optimize these three operations, where the 'net effect' of them is to have no operation. + * + * Therefor, this test asserts that after each invocation, no operation is scheduled. + */ + @Test + public void testDeleteAffiliationVoidsOlderCreateWithUpdate() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID affiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate affiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation affiliation = NodeAffiliate.Affiliation.publisher; + affiliate.setAffiliation(affiliation); + + // Execute system under test. + provider.createAffiliation(mockNode, affiliate); + provider.updateAffiliation(mockNode, affiliate); + provider.removeAffiliation(mockNode, affiliate); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#removeAffiliation(Node, NodeAffiliate)} + * attempts to remove the same affiliation 'twice'. + * + * This tests an erroneous invocation. The design of the caching provider is such that it shouldn't try to 'clean up' + * such misbehavior. Instead, it should pass this through to the delegate, which is expected to generate an + * appropriate error response. + * + * Therefor, this test asserts that for each invocation, a corresponding operation is scheduled. + */ + @Test + public void testDeleteAffiliationTwice() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID affiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate affiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation affiliation = NodeAffiliate.Affiliation.publisher; + affiliate.setAffiliation(affiliation); + + // Execute system under test. + provider.removeAffiliation(mockNode, affiliate); + provider.removeAffiliation(mockNode, affiliate); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstNodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), firstNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE_AFFILIATION, firstNodeOperation.action); + assertEquals(affiliateAddress, firstNodeOperation.affiliate.getJID()); + assertEquals(affiliation, firstNodeOperation.affiliate.getAffiliation()); + assertNull(firstNodeOperation.subscription); + + final CachingPubsubPersistenceProvider.NodeOperation secondNodeOperation = pending.get(1); + assertEquals(mockNode.getUniqueIdentifier(), secondNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE_AFFILIATION, secondNodeOperation.action); + assertEquals(affiliateAddress, secondNodeOperation.affiliate.getJID()); + assertEquals(affiliation, secondNodeOperation.affiliate.getAffiliation()); + assertNull(secondNodeOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createAffiliation(Node, NodeAffiliate)} + * and {@link CachingPubsubPersistenceProvider#removeAffiliation(Node, NodeAffiliate)} creates an affiliation, and + * then removes a different affiliation. + * + * As these operations relate to two different affiliations, the caching provider MUST NOT optimize these two + * operations (where the 'net effect' of them is to have no operation). + * + * Therefor, this test asserts that after both invocations, two operations are scheduled. + */ + @Test + public void testDeleteAffiliationAfterCreateDifferentAffiliation() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID firstAffiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate firstAffiliate = new NodeAffiliate(mockNode, firstAffiliateAddress); + final NodeAffiliate.Affiliation firstAffiliation = NodeAffiliate.Affiliation.publisher; + firstAffiliate.setAffiliation(firstAffiliation); + final JID secondAffiliateAddress = new JID("test-user-2", "example.org", null); + final NodeAffiliate secondAffiliate = new NodeAffiliate(mockNode, secondAffiliateAddress); + final NodeAffiliate.Affiliation secondAffiliation = NodeAffiliate.Affiliation.outcast; + secondAffiliate.setAffiliation(secondAffiliation); + + // Execute system under test. + provider.createAffiliation(mockNode, firstAffiliate); + provider.removeAffiliation(mockNode, secondAffiliate); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstNodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), firstNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_AFFILIATION, firstNodeOperation.action); + assertEquals(firstAffiliateAddress, firstNodeOperation.affiliate.getJID()); + assertEquals(firstAffiliation, firstNodeOperation.affiliate.getAffiliation()); + assertNull(firstNodeOperation.subscription); + + final CachingPubsubPersistenceProvider.NodeOperation secondNodeOperation = pending.get(1); + assertEquals(mockNode.getUniqueIdentifier(), secondNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE_AFFILIATION, secondNodeOperation.action); + assertEquals(secondAffiliateAddress, secondNodeOperation.affiliate.getJID()); + assertEquals(secondAffiliation, secondNodeOperation.affiliate.getAffiliation()); + assertNull(secondNodeOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#updateAffiliation(Node, NodeAffiliate)} + * attempts to update two different affiliations (with a different user but same node). + * + * As these operations relate to two different affiliations, the caching provider MUST NOT optimize these two + * operations (where the 'net effect' of them is to have no operation). + * + * Therefor, this test asserts that after both invocations, two operations are scheduled. + */ + @Test + public void testUpdateTwoAffiliations() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID firstAffiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate firstAffiliate = new NodeAffiliate(mockNode, firstAffiliateAddress); + final NodeAffiliate.Affiliation firstAffiliation = NodeAffiliate.Affiliation.publisher; + firstAffiliate.setAffiliation(firstAffiliation); + final JID secondAffiliateAddress = new JID("test-user-2", "example.org", null); + final NodeAffiliate secondAffiliate = new NodeAffiliate(mockNode, secondAffiliateAddress); + final NodeAffiliate.Affiliation secondAffiliation = NodeAffiliate.Affiliation.outcast; + secondAffiliate.setAffiliation(secondAffiliation); + + // Execute system under test. + provider.updateAffiliation(mockNode, firstAffiliate); + provider.updateAffiliation(mockNode, secondAffiliate); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstNodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), firstNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.UPDATE_AFFILIATION, firstNodeOperation.action); + assertEquals(firstAffiliateAddress, firstNodeOperation.affiliate.getJID()); + assertEquals(firstAffiliation, firstNodeOperation.affiliate.getAffiliation()); + assertNull(firstNodeOperation.subscription); + + final CachingPubsubPersistenceProvider.NodeOperation secondNodeOperation = pending.get(1); + assertEquals(mockNode.getUniqueIdentifier(), secondNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.UPDATE_AFFILIATION, secondNodeOperation.action); + assertEquals(secondAffiliateAddress, secondNodeOperation.affiliate.getJID()); + assertEquals(secondAffiliation, secondNodeOperation.affiliate.getAffiliation()); + assertNull(secondNodeOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createAffiliation(Node, NodeAffiliate)} + * and {@link CachingPubsubPersistenceProvider#removeNode(Node)} attempts to create an affiliation for a node, after + * which the node is removed. + * + * When a node is deleted, all its associated data (including items, affiliations and affiliations) are removed. + * Any pending operations on that node can thus be removed. + * + * The caching provider can optimize the operations, where the 'net effect' of them is to have only the remove-node + * operation. + * + * Therefor, this test asserts that after both invocations, one operation is scheduled. + */ + @Test + public void testDeleteNodeVoidsCreateAffiliation() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID affiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate affiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation affiliation = NodeAffiliate.Affiliation.publisher; + affiliate.setAffiliation(affiliation); + + // Execute system under test. + provider.createAffiliation(mockNode, affiliate); + provider.removeNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE, nodeOperation.action); + assertNull(nodeOperation.affiliate); + assertNull(nodeOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#updateAffiliation(Node, NodeAffiliate)} + * and {@link CachingPubsubPersistenceProvider#removeNode(Node)} attempts to update an affiliation for a node, after + * which the node is removed. + * + * When a node is deleted, all its associated data (including items, affiliations and affiliations) are removed. + * Any pending operations on that node can thus be removed. + * + * The caching provider can optimize the operations, where the 'net effect' of them is to have only the remove-node + * operation. + * + * Therefor, this test asserts that after both invocations, one operation is scheduled. + */ + @Test + public void testDeleteNodeVoidsUpdateAffiliation() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID affiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate affiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation affiliation = NodeAffiliate.Affiliation.publisher; + affiliate.setAffiliation(affiliation); + + // Execute system under test. + provider.updateAffiliation(mockNode, affiliate); + provider.removeNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE, nodeOperation.action); + assertNull(nodeOperation.affiliate); + assertNull(nodeOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#removeAffiliation(Node, NodeAffiliate)} + * and {@link CachingPubsubPersistenceProvider#removeNode(Node)} attempts to remove an affiliation for a node, after + * which the node is removed. + * + * When a node is deleted, all its associated data (including items, affiliations and affiliations) are removed. + * Any pending operations on that node can thus be removed. + * + * The caching provider can optimize the operations, where the 'net effect' of them is to have only the remove-node + * operation. + * + * Therefor, this test asserts that after both invocations, one operation is scheduled. + */ + @Test + public void testDeleteNodeVoidsDeleteAffiliation() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID affiliateAddress = new JID("test-user-1", "example.org", null); + final NodeAffiliate affiliate = new NodeAffiliate(mockNode, affiliateAddress); + final NodeAffiliate.Affiliation affiliation = NodeAffiliate.Affiliation.publisher; + affiliate.setAffiliation(affiliation); + + // Execute system under test. + provider.removeAffiliation(mockNode, affiliate); + provider.removeNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE, nodeOperation.action); + assertNull(nodeOperation.affiliate); + assertNull(nodeOperation.subscription); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java new file mode 100644 index 0000000000..f7390549fb --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java @@ -0,0 +1,339 @@ +/* + * Copyright (C) 2025 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.pubsub; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.xmpp.packet.JID; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies the implementation of {@link CachingPubsubPersistenceProvider} + * + * The unit tests in this class are limited to the operations that create and remove (published) items. + * + * @author Guus der Kinderen, guus@goodbytes.nl + */ +@ExtendWith(MockitoExtension.class) +public class CachingPubsubPersistenceProviderItemOperationsTest +{ + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#savePublishedItem(PublishedItem)} causes a + * corresponding item to be scheduled for processing. + */ + @Test + public void testSavePublishedItem() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final PublishedItem item = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + + // Execute system under test. + provider.savePublishedItem(item); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(1, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#removePublishedItem(PublishedItem)} causes a + * corresponding item to be scheduled for processing. + */ + @Test + public void testRemovePublishedItem() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final PublishedItem item = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + + // Execute system under test. + provider.removePublishedItem(item); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(1, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocations {@link CachingPubsubPersistenceProvider#savePublishedItem(PublishedItem)} to + * save two distinct items cause corresponding items to be scheduled for processing. + */ + @Test + public void testSaveTwoPublishedItemsDistinctIdentifier() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final PublishedItem itemA = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + final PublishedItem itemB = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + + // Execute system under test. + provider.savePublishedItem(itemA); + provider.savePublishedItem(itemB); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(2, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocations {@link CachingPubsubPersistenceProvider#savePublishedItem(PublishedItem)} to + * save two items with the same identifier causes the provider to optimize: the second 'save' will overwrite the + * first, thus that can be discarded. + */ + @Test + public void testSaveTwoPublishedItemsSameIdentifier() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final String identifier = UUID.randomUUID().toString(); + final PublishedItem itemA = new PublishedItem(mockNode, publisher, identifier, new Date()); + final PublishedItem itemB = new PublishedItem(mockNode, publisher, identifier, new Date()); + + // Execute system under test. + provider.savePublishedItem(itemA); + provider.savePublishedItem(itemB); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(1, provider.itemsToAdd.size()); + assertEquals(itemB.getID(), provider.itemsToAdd.getFirst().getID()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocations {@link CachingPubsubPersistenceProvider#removePublishedItem(PublishedItem)} to + * remove two distinct items cause corresponding items to be scheduled for processing. + */ + @Test + public void testRemoveTwoPublishedItemDistinctIdentifier() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final PublishedItem itemA = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + final PublishedItem itemB = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + + // Execute system under test. + provider.removePublishedItem(itemA); + provider.removePublishedItem(itemB); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(2, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocations {@link CachingPubsubPersistenceProvider#removePublishedItem(PublishedItem)} to + * save two items with the same identifier DOES NOT cause the provider to optimize: this is an erroneous invocation, + * and the delegate provider should (possibly) thrown an exception (or silently ignore the duplcate). + */ + @Test + public void testRemoveTwoPublishedItemsDistinctIdentifier() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final String identifier = UUID.randomUUID().toString(); + final PublishedItem itemA = new PublishedItem(mockNode, publisher, identifier, new Date()); + final PublishedItem itemB = new PublishedItem(mockNode, publisher, identifier, new Date()); + + // Execute system under test. + provider.removePublishedItem(itemA); + provider.removePublishedItem(itemB); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(2, provider.itemsToDelete.size()); + } + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#savePublishedItem(PublishedItem)} followed + * by an invocation of {@link CachingPubsubPersistenceProvider#removePublishedItem(PublishedItem)} to represent + * update and removal of the same item causes the provider to optimize: the update can be discarded. + * + * Note that a 'save' can represent either a 'create' or an 'update'. As the provider doesn't attempt to complete + * exact track of pre-existing items, the removal needs to be executed (and cannot be discarded). + */ + @Test + public void testSaveAndRemovePublishedItem() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final PublishedItem item = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + + // Execute system under test. + provider.savePublishedItem(item); + provider.removePublishedItem(item); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(1, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#removePublishedItem(PublishedItem)} followed + * by an invocation of {@link CachingPubsubPersistenceProvider#savePublishedItem(PublishedItem)} to represent + * removal and then creation of the same item causes the provider to optimize: the removal can be discarded. + * + * Note that the XML content can be different in both items. The latter save will still overwrite what would've been + * otherwise explicitly been deleted first. + */ + @Test + public void testRemoveAndSavePublishedItem() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final PublishedItem item = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + + // Execute system under test. + provider.removePublishedItem(item); + provider.savePublishedItem(item); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(1, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#savePublishedItem(PublishedItem)} + * and {@link CachingPubsubPersistenceProvider#removeNode(Node)} attempts to save an item in a node, after + * which the node is removed. + * + * When a node is deleted, all its associated data (including items, affiliations and affiliations) are removed. + * Any pending operations on that node can thus be removed. + * + * The caching provider can optimize the operations, where the 'net effect' of them is to have only the remove-node + * operation. + * + * Therefor, this test asserts that after both invocations, one operation is scheduled. + */ + @Test + public void testDeleteNodeVoidsSavePublishedItem() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final PubSubPersistenceProvider mockDelegate = Mockito.mock(PubSubPersistenceProvider.class); + provider.delegate = mockDelegate; + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final PublishedItem item = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + + // Execute system under test. + provider.savePublishedItem(item); + provider.removeNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE, nodeOperation.action); + assertNull(nodeOperation.affiliate); + assertNull(nodeOperation.subscription); + + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#removePublishedItem(PublishedItem)} causes a + * corresponding item to be scheduled for processing. + */ + @Test + public void testDeleteNodeVoidsRemovePublishedItem() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final PubSubPersistenceProvider mockDelegate = Mockito.mock(PubSubPersistenceProvider.class); + provider.delegate = mockDelegate; + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final PublishedItem item = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + + // Execute system under test. + provider.removePublishedItem(item); + provider.removeNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE, nodeOperation.action); + assertNull(nodeOperation.affiliate); + assertNull(nodeOperation.subscription); + + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderNodeOperationsTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderNodeOperationsTest.java new file mode 100644 index 0000000000..4fd2349a59 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderNodeOperationsTest.java @@ -0,0 +1,438 @@ +/* + * Copyright (C) 2025 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.pubsub; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies the implementation of {@link CachingPubsubPersistenceProvider} + * + * The unit tests in this class are limited to the operations that create, update and remove nodes. + * + * @author Guus der Kinderen, guus@goodbytes.nl + */ +@ExtendWith(MockitoExtension.class) +public class CachingPubsubPersistenceProviderNodeOperationsTest +{ + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#createNode(Node)} causes a corresponding + * operation to be scheduled. + */ + @Test + public void testCreateNode() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + + // Execute system under test. + provider.createNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE, nodeOperation.action); + assertNull(nodeOperation.subscription); + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#updateNode(Node)} causes a corresponding + * operation to be scheduled. + */ + @Test + public void testUpdateNode() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + + // Execute system under test. + provider.updateNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.UPDATE, nodeOperation.action); + assertNull(nodeOperation.subscription); + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#removeNode(Node)} causes a corresponding + * operation to be scheduled. + */ + @Test + public void testDeleteNode() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + + // Execute system under test. + provider.removeNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE, nodeOperation.action); + assertNull(nodeOperation.subscription); + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createNode(Node)} attempts to create the + * same node 'twice'. + * + * This tests an erroneous invocation. The design of the caching provider is such that it shouldn't try to 'clean up' + * such misbehavior. Instead, it should pass this through to the delegate, which is expected to generate an + * appropriate error response. + * + * Therefor, this test asserts that for each invocation, a corresponding operation is scheduled. + */ + @Test + public void testCreateNodeTwice() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockOrigNode = Mockito.mock(Node.class); + final Node.UniqueIdentifier nodeId = new Node.UniqueIdentifier("mock-service-1", "mock-node-a"); + Mockito.lenient().when(mockOrigNode.getUniqueIdentifier()).thenReturn(nodeId); + Mockito.lenient().when(mockOrigNode.getCreationDate()).thenReturn(new Date(0)); + final Node mockReplaceNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockReplaceNode.getUniqueIdentifier()).thenReturn(nodeId); + Mockito.lenient().when(mockReplaceNode.getCreationDate()).thenReturn(new Date(1)); + + // Execute system under test. + provider.createNode(mockOrigNode); + provider.createNode(mockReplaceNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(nodeId, id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstNodeOperation = pending.get(0); + assertEquals(mockOrigNode.getUniqueIdentifier(), firstNodeOperation.node.getUniqueIdentifier()); + assertEquals(new Date(0), firstNodeOperation.node.getCreationDate()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE, firstNodeOperation.action); + assertNull(firstNodeOperation.subscription); + assertNull(firstNodeOperation.affiliate); + + final CachingPubsubPersistenceProvider.NodeOperation secondNodeOperation = pending.get(1); + assertEquals(mockOrigNode.getUniqueIdentifier(), secondNodeOperation.node.getUniqueIdentifier()); + assertNotEquals(new Date(0), secondNodeOperation.node.getCreationDate()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE, secondNodeOperation.action); + assertNull(secondNodeOperation.subscription); + assertNull(secondNodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#updateNode(Node)} attempts to update the + * same node 'twice'. + * + * Updates of a node are never partial. This allows the caching provider to optimize two sequential updates. + * + * Therefor, this test asserts that after each invocation, only one operation, corresponding to the last update, is + * scheduled. + */ + @Test + public void testUpdateNodeTwice() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockOrigNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockOrigNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + Mockito.lenient().when(mockOrigNode.getCreationDate()).thenReturn(new Date(0)); + final Node mockReplaceNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockReplaceNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + Mockito.lenient().when(mockReplaceNode.getCreationDate()).thenReturn(new Date(1)); + + // Execute system under test. + provider.updateNode(mockOrigNode); + provider.updateNode(mockReplaceNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockOrigNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockOrigNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertNotEquals(new Date(0), nodeOperation.node.getCreationDate()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.UPDATE, nodeOperation.action); + assertNull(nodeOperation.subscription); + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createNode(Node)} and + * {@link CachingPubsubPersistenceProvider#removeNode(Node)} creates and immediately removes a node again. + * + * The caching provider can optimize these two operations, where the 'net effect' of them is to have no operation. + * + * Therefor, this test asserts that after each invocation, no operation is scheduled. + */ + @Test + public void testDeleteNodeAfterCreateNode() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + + // Execute system under test. + provider.createNode(mockNode); + provider.removeNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#removeNode(Node)} and + * {@link CachingPubsubPersistenceProvider#createNode(Node)} removes (a node that is thought to preexist) and then + * recreate a node again. + * + * This test asserts that after both invocations, two corresponding operation are scheduled in that order. + */ + @Test + public void testDeleteNodeDoesNotVoidNewerCreate() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + + // Execute system under test. + provider.removeNode(mockNode); + provider.createNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), firstOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE, firstOperation.action); + assertNull(firstOperation.subscription); + assertNull(firstOperation.affiliate); + + final CachingPubsubPersistenceProvider.NodeOperation secondOperation = pending.get(1); + assertEquals(mockNode.getUniqueIdentifier(), secondOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE, secondOperation.action); + assertNull(secondOperation.subscription); + assertNull(secondOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createNode(Node)}, + * {@link CachingPubsubPersistenceProvider#removeNode(Node)} and {@link CachingPubsubPersistenceProvider#createNode(Node)} + * creates, then removes and then recreate a node again. + * + * The caching provider can optimize the first two operations, where the 'net effect' of them is to have no operation. + * + * Therefor, this test asserts that after all invocations, only one operation that represents the last invocation, + * is scheduled. + */ + @Test + public void testDeleteNodeDoesNotVoidNewerCreate2() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + + // Execute system under test. + provider.createNode(mockNode); + provider.removeNode(mockNode); + provider.createNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE, nodeOperation.action); + assertNull(nodeOperation.subscription); + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#updateNode(Node)} and + * {@link CachingPubsubPersistenceProvider#removeNode(Node)} first updates (a node that is thought to preexist) and + * then removes a node. + * + * The caching provider can optimize the two operations, as the 'net effect' of them is to have removal. + * + * Therefor, this test asserts that after all invocations, only one operation that represents the last invocation, + * is scheduled. + */ + @Test + public void testDeleteNodeReplacesOlderUpdate() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + + // Execute system under test. + provider.updateNode(mockNode); + provider.removeNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE, nodeOperation.action); + assertNull(nodeOperation.subscription); + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createNode(Node)}, + * {@link CachingPubsubPersistenceProvider#updateNode(Node)} and {@link CachingPubsubPersistenceProvider#removeNode(Node)} + * a node is created, updated and immediately removes a node again. + * + * The caching provider can optimize these three operations, where the 'net effect' of them is to have no operation. + * + * Therefor, this test asserts that after each invocation, no operation is scheduled. + */ + @Test + public void testDeleteNodeVoidsOlderCreateWithUpdate() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + + // Execute system under test. + provider.createNode(mockNode); + provider.updateNode(mockNode); + provider.removeNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#removeNode(Node)} attempts to remove the + * same node 'twice'. + * + * This tests an erroneous invocation. The design of the caching provider is such that it shouldn't try to 'clean up' + * such misbehavior. Instead, it should pass this through to the delegate, which is expected to generate an + * appropriate error response. + * + * Therefor, this test asserts that for each invocation, a corresponding operation is scheduled. + */ + @Test + public void testDeleteNodeTwice() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + + // Execute system under test. + provider.removeNode(mockNode); + provider.removeNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstNodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), firstNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE, firstNodeOperation.action); + assertNull(firstNodeOperation.subscription); + assertNull(firstNodeOperation.affiliate); + + final CachingPubsubPersistenceProvider.NodeOperation secondNodeOperation = pending.get(1); + assertEquals(mockNode.getUniqueIdentifier(), secondNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE, secondNodeOperation.action); + assertNull(secondNodeOperation.subscription); + assertNull(secondNodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } +} diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderSubscriptionOperationsTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderSubscriptionOperationsTest.java new file mode 100644 index 0000000000..5ea0634615 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderSubscriptionOperationsTest.java @@ -0,0 +1,740 @@ +/* + * Copyright (C) 2025 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.pubsub; + +import org.checkerframework.checker.units.qual.N; +import org.jivesoftware.util.StringUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.xmpp.packet.JID; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies the implementation of {@link CachingPubsubPersistenceProvider} + * + * The unit tests in this class are limited to the operations that create, update and remove subscriptions. + * + * @author Guus der Kinderen, guus@goodbytes.nl + */ +@ExtendWith(MockitoExtension.class) +public class CachingPubsubPersistenceProviderSubscriptionOperationsTest +{ + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#createSubscription(Node, NodeSubscription)} + * causes a corresponding operation to be scheduled. + */ + @Test + public void testCreateSubscription() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String subscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription subscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.subscribed, subscriptionId); + + // Execute system under test. + provider.createSubscription(mockNode, subscription); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_SUBSCRIPTION, nodeOperation.action); + assertEquals(subscriptionId, nodeOperation.subscription.getID()); + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#updateSubscription(Node, NodeSubscription)} + * causes a corresponding operation to be scheduled. + */ + @Test + public void testUpdateSubscription() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String subscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription subscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.subscribed, subscriptionId); + + // Execute system under test. + provider.updateSubscription(mockNode, subscription); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.UPDATE_SUBSCRIPTION, nodeOperation.action); + assertEquals(subscriptionId, nodeOperation.subscription.getID()); + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#removeSubscription(NodeSubscription)} causes a corresponding + * operation to be scheduled. + */ + @Test + public void testDeleteSubscription() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String subscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription subscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.subscribed, subscriptionId); + + // Execute system under test. + provider.removeSubscription(subscription); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE_SUBSCRIPTION, nodeOperation.action); + assertEquals(subscriptionId, nodeOperation.subscription.getID()); + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createSubscription(Node, NodeSubscription)} + * attempts to create the same subscription 'twice'. + * + * This tests an erroneous invocation. The design of the caching provider is such that it shouldn't try to 'clean up' + * such misbehavior. Instead, it should pass this through to the delegate, which is expected to generate an + * appropriate error response. + * + * Therefor, this test asserts that for each invocation, a corresponding operation is scheduled. + */ + @Test + public void testCreateSubscriptionTwice() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String subscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription subscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.subscribed, subscriptionId); + + // Execute system under test. + provider.createSubscription(mockNode, subscription); + provider.createSubscription(mockNode, subscription); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstNodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), firstNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_SUBSCRIPTION, firstNodeOperation.action); + assertEquals(subscriptionId, firstNodeOperation.subscription.getID()); + assertNull(firstNodeOperation.affiliate); + + final CachingPubsubPersistenceProvider.NodeOperation secondNodeOperation = pending.get(1); + assertEquals(mockNode.getUniqueIdentifier(), secondNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_SUBSCRIPTION, secondNodeOperation.action); + assertEquals(subscriptionId, secondNodeOperation.subscription.getID()); + assertNull(secondNodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createSubscription(Node, NodeSubscription)} + * attempts to create the multiple, different subscriptions. + * + * This test asserts that for each invocation, a corresponding operation is scheduled. + */ + @Test + public void testCreateSubscriptions() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID firstSubscriber = new JID("test-user-1", "example.org", null); + final String firstSubscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription firstSubscription = new NodeSubscription(mockNode, firstSubscriber, firstSubscriber, NodeSubscription.State.subscribed, firstSubscriptionId); + final JID secondSubscriber = new JID("test-user-2", "example.org", null); // Technically, the same user can be subscribed more than once, but lets keep things simple. + final String secondSubscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription secondSubscription = new NodeSubscription(mockNode, secondSubscriber, secondSubscriber, NodeSubscription.State.subscribed, secondSubscriptionId); + + // Execute system under test. + provider.createSubscription(mockNode, firstSubscription); + provider.createSubscription(mockNode, secondSubscription); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstNodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), firstNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_SUBSCRIPTION, firstNodeOperation.action); + assertEquals(firstSubscriptionId, firstNodeOperation.subscription.getID()); + assertEquals(firstSubscriber, firstNodeOperation.subscription.getJID()); + assertEquals(firstSubscriber, firstNodeOperation.subscription.getOwner()); + assertNull(firstNodeOperation.affiliate); + + final CachingPubsubPersistenceProvider.NodeOperation secondNodeOperation = pending.get(1); + assertEquals(mockNode.getUniqueIdentifier(), secondNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_SUBSCRIPTION, secondNodeOperation.action); + assertEquals(secondSubscriptionId, secondNodeOperation.subscription.getID()); + assertEquals(secondSubscriber, secondNodeOperation.subscription.getJID()); + assertEquals(secondSubscriber, secondNodeOperation.subscription.getOwner()); + assertNull(secondNodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#updateSubscription(Node, NodeSubscription)} + * attempts to update the same subscription (with the same ID, same user and same node) 'twice'. + * + * Updates of a subscription are never partial. This allows the caching provider to optimize two sequential updates. + * + * Therefor, this test asserts that after each invocation, only one operation, corresponding to the last update, is + * scheduled. + */ + @Test + public void testUpdateSubscriptionTwice() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String subscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription firstSubscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.pending, subscriptionId); + final NodeSubscription secondSubscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.subscribed, subscriptionId); + + // Execute system under test. + provider.updateSubscription(mockNode, firstSubscription); + provider.updateSubscription(mockNode, secondSubscription); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.UPDATE_SUBSCRIPTION, nodeOperation.action); + assertEquals(subscriptionId, nodeOperation.subscription.getID()); + assertEquals(NodeSubscription.State.subscribed, nodeOperation.subscription.getState()); // match the second subscription. + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createSubscription(Node, NodeSubscription)} + * and {@link CachingPubsubPersistenceProvider#removeSubscription(NodeSubscription)} creates and immediately removes + * a subscription. + * + * The caching provider can optimize these two operations, where the 'net effect' of them is to have no operation. + * + * Therefor, this test asserts that after each invocation, no operation is scheduled. + */ + @Test + public void testDeleteSubscriptionAfterCreateSubscription() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String subscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription subscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.subscribed, subscriptionId); + + // Execute system under test. + provider.createSubscription(mockNode, subscription); + provider.removeSubscription(subscription); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#removeSubscription(NodeSubscription)} and + * {@link CachingPubsubPersistenceProvider#createSubscription(Node, NodeSubscription)} removes (a subscription that + * is thought to preexist) and then recreate a subscription again. + * + * This test asserts that after both invocations, two corresponding operation are scheduled in that order. + */ + @Test + public void testDeleteSubscriptionDoesNotVoidNewerCreate() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String subscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription subscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.subscribed, subscriptionId); + + // Execute system under test. + provider.removeSubscription(subscription); + provider.createSubscription(mockNode, subscription); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), firstOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE_SUBSCRIPTION, firstOperation.action); + assertEquals(subscriptionId, firstOperation.subscription.getID()); + assertNull(firstOperation.affiliate); + + final CachingPubsubPersistenceProvider.NodeOperation secondOperation = pending.get(1); + assertEquals(mockNode.getUniqueIdentifier(), secondOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_SUBSCRIPTION, secondOperation.action); + assertEquals(subscriptionId, secondOperation.subscription.getID()); + assertNull(secondOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createSubscription(Node, NodeSubscription)}, + * {@link CachingPubsubPersistenceProvider#removeSubscription(NodeSubscription)} and + * {@link CachingPubsubPersistenceProvider#createSubscription(Node, NodeSubscription)} + * creates, then removes and then recreate a subscription again. + * + * The caching provider can optimize the first two operations, where the 'net effect' of them is to have no operation. + * + * Therefor, this test asserts that after all invocations, only one operation that represents the last invocation, + * is scheduled. + */ + @Test + public void testDeleteSubscriptionDoesNotVoidNewerCreate2() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String subscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription subscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.subscribed, subscriptionId); + + // Execute system under test. + provider.createSubscription(mockNode, subscription); + provider.removeSubscription(subscription); + provider.createSubscription(mockNode, subscription); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_SUBSCRIPTION, nodeOperation.action); + assertEquals(subscriptionId, nodeOperation.subscription.getID()); + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#updateSubscription(Node, NodeSubscription)} + * and {@link CachingPubsubPersistenceProvider#removeSubscription(NodeSubscription)} first updates (a subscription + * that is thought to preexist) and then removes that subscription. + * + * The caching provider can optimize the two operations, as the 'net effect' of them is to have removal. + * + * Therefor, this test asserts that after all invocations, only one operation that represents the last invocation, + * is scheduled. + */ + @Test + public void testDeleteSubscriptionReplacesOlderUpdate() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String subscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription subscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.subscribed, subscriptionId); + + // Execute system under test. + provider.updateSubscription(mockNode, subscription); + provider.removeSubscription(subscription); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE_SUBSCRIPTION, nodeOperation.action); + assertEquals(subscriptionId, nodeOperation.subscription.getID()); + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createSubscription(Node, NodeSubscription)}, + * {@link CachingPubsubPersistenceProvider#updateSubscription(Node, NodeSubscription)} and + * {@link CachingPubsubPersistenceProvider#removeSubscription(NodeSubscription)} a subscription is created, updated + * and immediately removes again. + * + * The caching provider can optimize these three operations, where the 'net effect' of them is to have no operation. + * + * Therefor, this test asserts that after each invocation, no operation is scheduled. + */ + @Test + public void testDeleteSubscriptionVoidsOlderCreateWithUpdate() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String subscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription subscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.subscribed, subscriptionId); + + // Execute system under test. + provider.createSubscription(mockNode, subscription); + provider.updateSubscription(mockNode, subscription); + provider.removeSubscription(subscription); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#removeSubscription(NodeSubscription)} + * attempts to remove the same subscription 'twice'. + * + * This tests an erroneous invocation. The design of the caching provider is such that it shouldn't try to 'clean up' + * such misbehavior. Instead, it should pass this through to the delegate, which is expected to generate an + * appropriate error response. + * + * Therefor, this test asserts that for each invocation, a corresponding operation is scheduled. + */ + @Test + public void testDeleteSubscriptionTwice() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String subscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription subscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.subscribed, subscriptionId); + + // Execute system under test. + provider.removeSubscription(subscription); + provider.removeSubscription(subscription); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstNodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), firstNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE_SUBSCRIPTION, firstNodeOperation.action); + assertEquals(subscriptionId, firstNodeOperation.subscription.getID()); + assertNull(firstNodeOperation.affiliate); + + final CachingPubsubPersistenceProvider.NodeOperation secondNodeOperation = pending.get(1); + assertEquals(mockNode.getUniqueIdentifier(), secondNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE_SUBSCRIPTION, secondNodeOperation.action); + assertEquals(subscriptionId, firstNodeOperation.subscription.getID()); + assertNull(secondNodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createSubscription(Node, NodeSubscription)} + * and {@link CachingPubsubPersistenceProvider#removeSubscription(NodeSubscription)} creates a subscription, and + * then removes a different subscription. + * + * As these operations relate to two different subscriptions, the caching provider MUST NOT optimize these two + * operations (where the 'net effect' of them is to have no operation). + * + * Therefor, this test asserts that after both invocations, two operations are scheduled. + */ + @Test + public void testDeleteSubscriptionAfterCreateDifferentSubscription() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String firstSubscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription firstSubscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.pending, firstSubscriptionId); + final String secondSubscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription secondSubscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.unconfigured, secondSubscriptionId); + + // Execute system under test. + provider.createSubscription(mockNode, firstSubscription); + provider.removeSubscription(secondSubscription); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstNodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), firstNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.CREATE_SUBSCRIPTION, firstNodeOperation.action); + assertEquals(firstSubscriptionId, firstNodeOperation.subscription.getID()); + assertNull(firstNodeOperation.affiliate); + + final CachingPubsubPersistenceProvider.NodeOperation secondNodeOperation = pending.get(1); + assertEquals(mockNode.getUniqueIdentifier(), secondNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE_SUBSCRIPTION, secondNodeOperation.action); + assertEquals(secondSubscriptionId, secondNodeOperation.subscription.getID()); + assertNull(secondNodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#updateSubscription(Node, NodeSubscription)} + * attempts to update two different subscriptions (with a different ID, but same user and same node). + * + * As these operations relate to two different subscriptions, the caching provider MUST NOT optimize these two + * operations (where the 'net effect' of them is to have no operation). + * + * Therefor, this test asserts that after both invocations, two operations are scheduled. + */ + @Test + public void testUpdateTwoSubscriptions() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String firstSubscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription firstSubscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.pending, firstSubscriptionId); + final String secondSubscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription secondSubscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.unconfigured, secondSubscriptionId); + + // Execute system under test. + provider.updateSubscription(mockNode, firstSubscription); + provider.updateSubscription(mockNode, secondSubscription); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(2, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation firstNodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), firstNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.UPDATE_SUBSCRIPTION, firstNodeOperation.action); + assertEquals(firstSubscriptionId, firstNodeOperation.subscription.getID()); + assertNull(firstNodeOperation.affiliate); + + final CachingPubsubPersistenceProvider.NodeOperation secondNodeOperation = pending.get(1); + assertEquals(mockNode.getUniqueIdentifier(), secondNodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.UPDATE_SUBSCRIPTION, secondNodeOperation.action); + assertEquals(secondSubscriptionId, secondNodeOperation.subscription.getID()); + assertNull(secondNodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#createSubscription(Node, NodeSubscription)} + * and {@link CachingPubsubPersistenceProvider#removeNode(Node)} attempts to create a subscription for a node, after + * which the node is removed. + * + * When a node is deleted, all its associated data (including items, subscriptions and affiliations) are removed. + * Any pending operations on that node can thus be removed. + * + * The caching provider can optimize the operations, where the 'net effect' of them is to have only the remove-node + * operation. + * + * Therefor, this test asserts that after both invocations, one operation is scheduled. + */ + @Test + public void testDeleteNodeVoidsCreateSubscription() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String subscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription subscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.subscribed, subscriptionId); + + // Execute system under test. + provider.createSubscription(mockNode, subscription); + provider.removeNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE, nodeOperation.action); + assertNull(nodeOperation.subscription); + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#updateSubscription(Node, NodeSubscription)} + * and {@link CachingPubsubPersistenceProvider#removeNode(Node)} attempts to update a subscription for a node, after + * which the node is removed. + * + * When a node is deleted, all its associated data (including items, subscriptions and affiliations) are removed. + * Any pending operations on that node can thus be removed. + * + * The caching provider can optimize the operations, where the 'net effect' of them is to have only the remove-node + * operation. + * + * Therefor, this test asserts that after both invocations, one operation is scheduled. + */ + @Test + public void testDeleteNodeVoidsUpdateSubscription() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String subscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription subscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.subscribed, subscriptionId); + + // Execute system under test. + provider.updateSubscription(mockNode, subscription); + provider.removeNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE, nodeOperation.action); + assertNull(nodeOperation.subscription); + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Executes a test that, through {@link CachingPubsubPersistenceProvider#removeSubscription(NodeSubscription)} + * and {@link CachingPubsubPersistenceProvider#removeNode(Node)} attempts to remove a subscription for a node, after + * which the node is removed. + * + * When a node is deleted, all its associated data (including items, subscriptions and affiliations) are removed. + * Any pending operations on that node can thus be removed. + * + * The caching provider can optimize the operations, where the 'net effect' of them is to have only the remove-node + * operation. + * + * Therefor, this test asserts that after both invocations, one operation is scheduled. + */ + @Test + public void testDeleteNodeVoidsDeleteSubscription() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final Node mockNode = Mockito.mock(Node.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID subscriber = new JID("test-user-1", "example.org", null); + final String subscriptionId = "test-subscription-id-" + StringUtils.randomString(7); + final NodeSubscription subscription = new NodeSubscription(mockNode, subscriber, subscriber, NodeSubscription.State.subscribed, subscriptionId); + + // Execute system under test. + provider.removeSubscription(subscription); + provider.removeNode(mockNode); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(1, pending.size()); + + final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); + assertEquals(mockNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.REMOVE, nodeOperation.action); + assertNull(nodeOperation.subscription); + assertNull(nodeOperation.affiliate); + + assertEquals(0, provider.itemsPending.size()); + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } +} From 0dc8175a874fcadafe7f3459dc856564bb8b9519 Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Fri, 24 Jan 2025 20:05:50 +0100 Subject: [PATCH 2/8] OF-2437: Fix various issues in CachingPubsubPersistenceProvider The changes in this commit address the issues that are exposed in the unit tests that are added in the previous commit. --- .../CachingPubsubPersistenceProvider.java | 65 ++++++++++++++++--- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProvider.java b/xmppserver/src/main/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProvider.java index 603ca59da3..25fc172c6a 100644 --- a/xmppserver/src/main/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProvider.java +++ b/xmppserver/src/main/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProvider.java @@ -37,10 +37,25 @@ import java.util.concurrent.locks.Lock; import java.util.stream.Collectors; +/** + * A persistence provider for Pub/Sub functionality that adds caching behavior. Instead of 'writing through' to the + * persistence layer, the caching implementation will create batches of operations (optimizing away redundant actions). + * Additionally, recently accessed published data items are cached. This improves performance when processing many + * pub/sub operations (node modifiations, item publications, etc). + * + * This provider itself does not persist data. Instead, it uses a different persistence provider as a delegate to + * perform these actions. + * + * @author Guus der Kinderen, guus@goodbytes.nl + */ public class CachingPubsubPersistenceProvider implements PubSubPersistenceProvider { private static final Logger log = LoggerFactory.getLogger(CachingPubsubPersistenceProvider.class); + /** + * The class definition used to instantiate the delegate, used by this instance to interact with persistent data + * storage. + */ public static final SystemProperty DELEGATE = SystemProperty.Builder.ofType(Class.class) .setKey("provider.pubsub-persistence.caching.delegate-className") .setBaseClass(PubSubPersistenceProvider.class) @@ -58,12 +73,12 @@ public class CachingPubsubPersistenceProvider implements PubSubPersistenceProvid * Pseudo-random number generator is used to offset timing for scheduled tasks * within a cluster (so they don't run at the same time on all members). */ - private Random prng = new Random(); + private final Random prng = new Random(); /** * Flush timer delay is configurable, but not less than 20 seconds (default: 2 mins) */ - private static Duration flushTimerDelay = Duration.ofSeconds(Math.max( 20, JiveGlobals.getIntProperty( "xmpp.pubsub.flush.timer", 120))); + private static final Duration flushTimerDelay = Duration.ofSeconds(Math.max( 20, JiveGlobals.getIntProperty( "xmpp.pubsub.flush.timer", 120))); /** * Maximum number of published items allowed in the write cache @@ -190,6 +205,11 @@ private void flushPendingNode( Node.UniqueIdentifier uniqueIdentifier ) { log.trace( "Flushing pending node: {} for service: {}", uniqueIdentifier.getNodeId(), uniqueIdentifier.getServiceIdentifier().getServiceId() ); + nodesToProcess.computeIfPresent( uniqueIdentifier , ( key, operations ) -> { + operations.forEach( operation -> log.trace("- {}", operation) ); + // Returning null causes the mapping to removed from nodesToProcess. + return null; + } ); // TODO verify if this is having the desired effect. - nodes could be in a hierarchy, which could warrant for flushing the entire tree. // TODO verify that this is thread-safe. nodesToProcess.computeIfPresent( uniqueIdentifier , ( key, operations ) -> { @@ -243,8 +263,12 @@ public void removeNode(Node node) { } final List operations = nodesToProcess.computeIfAbsent( node.getUniqueIdentifier(), id -> new ArrayList<>() ); - operations.clear(); // Any previously recorded, but as of yet unsaved operations, can be skipped. - operations.add( NodeOperation.remove( node )); + final boolean hadCreate = operations.stream().anyMatch(operation -> operation.action.equals(NodeOperation.Action.CREATE)); + operations.removeIf(operation -> !operation.action.equals(NodeOperation.Action.REMOVE)); // Any previously recorded, but as of yet unsaved operations, can be skipped. + + if (!hadCreate) { // If one of the operations that have not been executed was a node create, we need not delete the node either. + operations.add(NodeOperation.remove(node)); + } } @Override @@ -323,18 +347,25 @@ public void removeAffiliation(Node node, NodeAffiliate affiliate) { final List operations = nodesToProcess.computeIfAbsent( node.getUniqueIdentifier(), id -> new ArrayList<>() ); // This affiliation removal can replace any pending creation, update or delete of the same affiliate (since the last create/delete of the node or affiliation change of this affiliate to the node). + boolean hadCreate = false; final ListIterator iter = operations.listIterator( operations.size() ); while ( iter.hasPrevious() ) { final NodeOperation operation = iter.previous(); - if ( Arrays.asList( NodeOperation.Action.CREATE_AFFILIATION, NodeOperation.Action.UPDATE_AFFILIATION, NodeOperation.Action.REMOVE_AFFILIATION ).contains( operation.action ) ) { + if ( Arrays.asList( NodeOperation.Action.CREATE_AFFILIATION, NodeOperation.Action.UPDATE_AFFILIATION ).contains( operation.action ) ) { if ( affiliate.getJID().equals( operation.affiliate.getJID() ) ) { + if (operation.action.equals(NodeOperation.Action.CREATE_AFFILIATION)) { + hadCreate = true; + } iter.remove(); // This is replaced by the update that's being added. } } else { break; // Operations that precede anything other than the last operations that are affiliate changes shouldn't be replaced. } } - operations.add( NodeOperation.removeAffiliation( node, affiliate ) ); + + if (!hadCreate) { // If one of the operations that have not been executed was an affiliation create, we need not delete the affiliation either. + operations.add(NodeOperation.removeAffiliation(node, affiliate)); + } } @Override @@ -374,19 +405,26 @@ public void removeSubscription(NodeSubscription subscription) { final List operations = nodesToProcess.computeIfAbsent( subscription.getNode().getUniqueIdentifier(), id -> new ArrayList<>() ); - // This subscription removal can replace any pending creation, update or delete of the same subscription (since the last create/delete of the node or subscription change of this subscription to the node). + // This subscription removal can replace any pending creation or update of the same subscription (since the last create/delete of the node or subscription change of this subscription to the node). + boolean hadCreate = false; final ListIterator iter = operations.listIterator( operations.size() ); while ( iter.hasPrevious() ) { final NodeOperation operation = iter.previous(); - if ( Arrays.asList( NodeOperation.Action.CREATE_SUBSCRIPTION, NodeOperation.Action.UPDATE_SUBSCRIPTION, NodeOperation.Action.REMOVE_SUBSCRIPTION ).contains( operation.action ) ) { + if ( Arrays.asList( NodeOperation.Action.CREATE_SUBSCRIPTION, NodeOperation.Action.UPDATE_SUBSCRIPTION ).contains( operation.action ) ) { if ( subscription.getID().equals( operation.subscription.getID() ) ) { + if (operation.action == NodeOperation.Action.CREATE_SUBSCRIPTION) { + hadCreate = true; + } iter.remove(); // This is replaced by the update that's being added. } } else { break; // Operations that precede anything other than the last operations that are subscription changes shouldn't be replaced. } } - operations.add( NodeOperation.removeSubscription( subscription.getNode(), subscription ) ); + + if (!hadCreate) { // If one of the operations that have not been executed was a subscription create, we need not delete the subscription either. + operations.add(NodeOperation.removeSubscription(subscription.getNode(), subscription)); + } } private void process( final NodeOperation operation ) { @@ -470,6 +508,10 @@ public void savePublishedItem(PublishedItem item) { if (itemToReplace != null) { itemsToAdd.remove(itemToReplace); // remove duplicate from itemsToAdd linked list } + + // TODO this iterates over all elements in the collection. See if this can be improved for performance. + itemsToDelete.removeIf(scheduledItem -> item.getUniqueIdentifier().equals(scheduledItem.getUniqueIdentifier())); + itemsToAdd.addLast(item); itemsPending.put(itemKey, item); } @@ -609,7 +651,10 @@ public void removePublishedItem(PublishedItem item) { synchronized (itemsPending) { itemsToDelete.addLast(item); - itemsPending.remove(itemKey); + PublishedItem itemToReplace = itemsPending.remove(itemKey); + if (itemToReplace != null) { + itemsToAdd.remove(itemToReplace); // remove duplicate from itemsToAdd linked list + } } } From 2ebd05d6930d8be8f2d57357c109a45903a13934 Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Fri, 7 Feb 2025 10:36:51 +0100 Subject: [PATCH 3/8] Update xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java Co-authored-by: Dan Caseley --- .../CachingPubsubPersistenceProviderItemOperationsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java index f7390549fb..cf10e88eef 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java @@ -178,7 +178,7 @@ public void testRemoveTwoPublishedItemDistinctIdentifier() throws Exception * and the delegate provider should (possibly) thrown an exception (or silently ignore the duplcate). */ @Test - public void testRemoveTwoPublishedItemsDistinctIdentifier() throws Exception + public void testRemoveTwoPublishedItemsSameIdentifier() throws Exception { // Setup test fixture. final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); From 68b19b69e3f6f9287991c40ab9082816f486e167 Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Fri, 7 Feb 2025 10:37:38 +0100 Subject: [PATCH 4/8] Update xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java Co-authored-by: Dan Caseley --- ...chingPubsubPersistenceProviderAffiliationOperationsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java index 4c1b432762..3ff1056381 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java @@ -543,7 +543,7 @@ public void testDeleteAffiliationTwice() throws Exception * then removes a different affiliation. * * As these operations relate to two different affiliations, the caching provider MUST NOT optimize these two - * operations (where the 'net effect' of them is to have no operation). + * operations (where the 'net effect' of them would be to have no operation). * * Therefor, this test asserts that after both invocations, two operations are scheduled. */ From a07da345345db0f77c301c629d4bf08d1c660245 Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Fri, 7 Feb 2025 10:38:45 +0100 Subject: [PATCH 5/8] Update xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java Co-authored-by: Dan Caseley --- ...PubsubPersistenceProviderItemOperationsTest.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java index cf10e88eef..29db6a5d2b 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java @@ -304,8 +304,17 @@ public void testDeleteNodeVoidsSavePublishedItem() throws Exception } /** - * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#removePublishedItem(PublishedItem)} causes a - * corresponding item to be scheduled for processing. + * Executes a test that, through {@link CachingPubsubPersistenceProvider#removePublishedItem(PublishedItem)} + * and {@link CachingPubsubPersistenceProvider#removeNode(Node)} attempts to remove an item from a node, after + * which the node is removed. + * + * When a node is deleted, all its associated data (including items, affiliations and affiliations) are removed. + * Any pending operations on that node can thus be removed. + * + * The caching provider can optimize the operations, where the 'net effect' of them is to have only the remove-node + * operation. + * + * Therefor, this test asserts that after both invocations, one operation is scheduled. */ @Test public void testDeleteNodeVoidsRemovePublishedItem() throws Exception From dbd7764e576a4c144002c3fd16586264e3b894fb Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Fri, 7 Feb 2025 10:39:52 +0100 Subject: [PATCH 6/8] Update xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderNodeOperationsTest.java Co-authored-by: Dan Caseley --- .../CachingPubsubPersistenceProviderNodeOperationsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderNodeOperationsTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderNodeOperationsTest.java index 4fd2349a59..8b3465e3ec 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderNodeOperationsTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderNodeOperationsTest.java @@ -206,7 +206,7 @@ public void testUpdateNodeTwice() throws Exception assertEquals(1, pending.size()); final CachingPubsubPersistenceProvider.NodeOperation nodeOperation = pending.get(0); - assertEquals(mockOrigNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); + assertEquals(mockReplaceNode.getUniqueIdentifier(), nodeOperation.node.getUniqueIdentifier()); assertNotEquals(new Date(0), nodeOperation.node.getCreationDate()); assertEquals(CachingPubsubPersistenceProvider.NodeOperation.Action.UPDATE, nodeOperation.action); assertNull(nodeOperation.subscription); From 9534812ab0558646c45046123274dfb163e9f1fb Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Fri, 7 Feb 2025 10:40:09 +0100 Subject: [PATCH 7/8] Update xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java Co-authored-by: Dan Caseley --- .../CachingPubsubPersistenceProviderItemOperationsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java index 29db6a5d2b..a6c8bd86f6 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java @@ -174,7 +174,7 @@ public void testRemoveTwoPublishedItemDistinctIdentifier() throws Exception /** * Asserts that an invocations {@link CachingPubsubPersistenceProvider#removePublishedItem(PublishedItem)} to - * save two items with the same identifier DOES NOT cause the provider to optimize: this is an erroneous invocation, + * remove two items with the same identifier DOES NOT cause the provider to optimize: this is an erroneous invocation, * and the delegate provider should (possibly) thrown an exception (or silently ignore the duplcate). */ @Test From a72cc70b7d21b770b0837efa710a0a0149110844 Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Fri, 7 Feb 2025 10:48:10 +0100 Subject: [PATCH 8/8] Update xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java Co-authored-by: Dan Caseley --- ...chingPubsubPersistenceProviderAffiliationOperationsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java index 3ff1056381..18859c4242 100644 --- a/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java @@ -595,7 +595,7 @@ public void testDeleteAffiliationAfterCreateDifferentAffiliation() throws Except * attempts to update two different affiliations (with a different user but same node). * * As these operations relate to two different affiliations, the caching provider MUST NOT optimize these two - * operations (where the 'net effect' of them is to have no operation). + * operations (where the 'net effect' of them would be to have one operation). * * Therefor, this test asserts that after both invocations, two operations are scheduled. */