From 565c5f12193a126fffdd6d82c3784fa1bcc0a44b Mon Sep 17 00:00:00 2001 From: Guus der Kinderen Date: Wed, 22 Jan 2025 21:58:31 +0100 Subject: [PATCH] OF-2437: Introduce CachingPubsubPersistenceProviderTest These new unit tests verify behavior of CachingPubsubPersistenceProvider (which fails, as indicated by some of these test failing). The tests introduced here cover four types of operations that are being scheduled by the implementation: - Node creation/changing/removal - Subscription creation/update/removal - Affiliation creation/update/removal - publishing/removal of items --- .../CachingPubsubPersistenceProvider.java | 15 +- ...enceProviderAffiliationOperationsTest.java | 776 ++++++++++++++++++ ...PersistenceProviderItemOperationsTest.java | 259 ++++++ ...PersistenceProviderNodeOperationsTest.java | 438 ++++++++++ ...nceProviderSubscriptionOperationsTest.java | 740 +++++++++++++++++ 5 files changed, 2223 insertions(+), 5 deletions(-) create mode 100644 xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderAffiliationOperationsTest.java create mode 100644 xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java create mode 100644 xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderNodeOperationsTest.java create mode 100644 xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderSubscriptionOperationsTest.java diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProvider.java b/xmppserver/src/main/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProvider.java index 405e337f25..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/CachingPubsubPersistenceProviderItemOperationsTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java new file mode 100644 index 0000000000..e41bb20703 --- /dev/null +++ b/xmppserver/src/test/java/org/jivesoftware/openfire/pubsub/CachingPubsubPersistenceProviderItemOperationsTest.java @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2025 Ignite Realtime Foundation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.openfire.pubsub; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.xmpp.packet.JID; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies the implementation of {@link CachingPubsubPersistenceProvider} + * + * The unit tests in this class are limited to the operations that create and remove (published) items. + * + * @author Guus der Kinderen, guus@goodbytes.nl + */ +@ExtendWith(MockitoExtension.class) +public class CachingPubsubPersistenceProviderItemOperationsTest +{ + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#savePublishedItem(PublishedItem)} causes a + * corresponding item to be scheduled for processing. + */ + @Test + public void testSavePublishedItem() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final PublishedItem item = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + + // Execute system under test. + provider.savePublishedItem(item); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(1, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#removePublishedItem(PublishedItem)} causes a + * corresponding item to be scheduled for processing. + */ + @Test + public void testRemovePublishedItem() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final PublishedItem item = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + + // Execute system under test. + provider.removePublishedItem(item); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(1, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocations {@link CachingPubsubPersistenceProvider#savePublishedItem(PublishedItem)} to + * save two distinct items cause corresponding items to be scheduled for processing. + */ + @Test + public void testSaveTwoPublishedItemsDistinctIdentifier() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final PublishedItem itemA = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + final PublishedItem itemB = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + + // Execute system under test. + provider.savePublishedItem(itemA); + provider.savePublishedItem(itemB); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(2, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocations {@link CachingPubsubPersistenceProvider#savePublishedItem(PublishedItem)} to + * save two items with the same identifier causes the provider to optimize: the second 'save' will overwrite the + * first, thus that can be discarded. + */ + @Test + public void testSaveTwoPublishedItemsSameIdentifier() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final String identifier = UUID.randomUUID().toString(); + final PublishedItem itemA = new PublishedItem(mockNode, publisher, identifier, new Date()); + final PublishedItem itemB = new PublishedItem(mockNode, publisher, identifier, new Date()); + + // Execute system under test. + provider.savePublishedItem(itemA); + provider.savePublishedItem(itemB); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(1, provider.itemsToAdd.size()); + assertEquals(itemB.getID(), provider.itemsToAdd.getFirst().getID()); + assertEquals(0, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocations {@link CachingPubsubPersistenceProvider#removePublishedItem(PublishedItem)} to + * remove two distinct items cause corresponding items to be scheduled for processing. + */ + @Test + public void testRemoveTwoPublishedItemDistinctIdentifier() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final PublishedItem itemA = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + final PublishedItem itemB = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + + // Execute system under test. + provider.removePublishedItem(itemA); + provider.removePublishedItem(itemB); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(2, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocations {@link CachingPubsubPersistenceProvider#removePublishedItem(PublishedItem)} to + * save two items with the same identifier DOES NOT cause the provider to optimize: this is an erroneous invocation, + * and the delegate provider should (possibly) thrown an exception (or silently ignore the duplcate). + */ + @Test + public void testRemoveTwoPublishedItemsDistinctIdentifier() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final String identifier = UUID.randomUUID().toString(); + final PublishedItem itemA = new PublishedItem(mockNode, publisher, identifier, new Date()); + final PublishedItem itemB = new PublishedItem(mockNode, publisher, identifier, new Date()); + + // Execute system under test. + provider.removePublishedItem(itemA); + provider.removePublishedItem(itemB); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(2, provider.itemsToDelete.size()); + } + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#savePublishedItem(PublishedItem)} followed + * by an invocation of {@link CachingPubsubPersistenceProvider#removePublishedItem(PublishedItem)} to represent + * update and removal of the same item causes the provider to optimize: the update can be discarded. + * + * Note that a 'save' can represent either a 'create' or an 'update'. As the provider doesn't attempt to complete + * exact track of pre-existing items, the removal needs to be executed (and cannot be discarded). + */ + @Test + public void testSaveAndRemovePublishedItem() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final PublishedItem item = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + + // Execute system under test. + provider.savePublishedItem(item); + provider.removePublishedItem(item); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(0, provider.itemsToAdd.size()); + assertEquals(1, provider.itemsToDelete.size()); + } + + /** + * Asserts that an invocation of {@link CachingPubsubPersistenceProvider#removePublishedItem(PublishedItem)} followed + * by an invocation of {@link CachingPubsubPersistenceProvider#savePublishedItem(PublishedItem)} to represent + * removal and then creation of the same item causes the provider to optimize: the removal can be discarded. + */ + @Test + public void testRemoveAndSavePublishedItem() throws Exception + { + // Setup test fixture. + final CachingPubsubPersistenceProvider provider = new CachingPubsubPersistenceProvider(); + final LeafNode mockNode = Mockito.mock(LeafNode.class); + Mockito.lenient().when(mockNode.getUniqueIdentifier()).thenReturn(new Node.UniqueIdentifier("mock-service-1", "mock-node-a")); + final JID publisher = new JID("test-user-1", "example.org", null); + final PublishedItem item = new PublishedItem(mockNode, publisher, UUID.randomUUID().toString(), new Date()); + + // Execute system under test. + provider.removePublishedItem(item); + provider.savePublishedItem(item); + + // Verify results. + final List pending = provider.nodesToProcess.computeIfAbsent(mockNode.getUniqueIdentifier(), id -> new ArrayList<>()); + assertEquals(0, pending.size()); + + assertEquals(1, provider.itemsToAdd.size()); + assertEquals(0, provider.itemsToDelete.size()); + } +} 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()); + } +}