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..6a2a17e6a3 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; @@ -69,20 +70,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..f92d458a4a --- /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. + * + * 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. + * + * 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. + * + * 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/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..b5723f13a2 --- /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. + * + * 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. + * + * 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. + * + * 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()); + } +}