/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.apache.jackrabbit.oak.jcr.observation;

import static java.util.Collections.synchronizedList;
import static java.util.Collections.synchronizedSet;
import static java.util.concurrent.Executors.newSingleThreadScheduledExecutor;
import static javax.jcr.observation.Event.NODE_ADDED;
import static javax.jcr.observation.Event.NODE_MOVED;
import static javax.jcr.observation.Event.NODE_REMOVED;
import static javax.jcr.observation.Event.PERSIST;
import static javax.jcr.observation.Event.PROPERTY_ADDED;
import static javax.jcr.observation.Event.PROPERTY_CHANGED;
import static javax.jcr.observation.Event.PROPERTY_REMOVED;
import static org.apache.jackrabbit.JcrConstants.JCR_MIXINTYPES;
import static org.apache.jackrabbit.JcrConstants.JCR_PRIMARYTYPE;
import static org.apache.jackrabbit.JcrConstants.NT_UNSTRUCTURED;
import static org.apache.jackrabbit.oak.api.Type.STRING;
import static org.hamcrest.CoreMatchers.containsString;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.fail;
import static org.junit.Assume.assumeTrue;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;

import javax.jcr.AccessDeniedException;
import javax.jcr.InvalidItemStateException;
import javax.jcr.ItemExistsException;
import javax.jcr.Node;
import javax.jcr.PathNotFoundException;
import javax.jcr.Property;
import javax.jcr.PropertyType;
import javax.jcr.ReferentialIntegrityException;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.Value;
import javax.jcr.lock.LockException;
import javax.jcr.nodetype.ConstraintViolationException;
import javax.jcr.nodetype.NoSuchNodeTypeException;
import javax.jcr.nodetype.NodeDefinition;
import javax.jcr.nodetype.NodeDefinitionTemplate;
import javax.jcr.nodetype.NodeTypeManager;
import javax.jcr.nodetype.NodeTypeTemplate;
import javax.jcr.observation.Event;
import javax.jcr.observation.EventIterator;
import javax.jcr.observation.EventListener;
import javax.jcr.observation.ObservationManager;
import javax.jcr.version.VersionException;

import org.apache.jackrabbit.guava.common.util.concurrent.ForwardingListenableFuture;
import org.apache.jackrabbit.guava.common.util.concurrent.Futures;
import org.apache.jackrabbit.guava.common.util.concurrent.ListenableFuture;
import org.apache.jackrabbit.guava.common.util.concurrent.SettableFuture;

import junitx.util.PrivateAccessor;

import org.apache.jackrabbit.JcrConstants;
import org.apache.jackrabbit.api.JackrabbitNode;
import org.apache.jackrabbit.api.observation.JackrabbitEventFilter;
import org.apache.jackrabbit.api.observation.JackrabbitObservationManager;
import org.apache.jackrabbit.oak.api.PropertyState;
import org.apache.jackrabbit.oak.commons.PathUtils;
import org.apache.jackrabbit.oak.commons.collections.IterableUtils;
import org.apache.jackrabbit.oak.commons.collections.SetUtils;
import org.apache.jackrabbit.oak.fixture.NodeStoreFixture;
import org.apache.jackrabbit.oak.jcr.AbstractRepositoryTest;
import org.apache.jackrabbit.oak.jcr.observation.filter.FilterFactory;
import org.apache.jackrabbit.oak.jcr.observation.filter.OakEventFilter;
import org.apache.jackrabbit.oak.plugins.observation.filter.ChangeSetFilterImpl;
import org.apache.jackrabbit.oak.plugins.observation.filter.FilterBuilder;
import org.apache.jackrabbit.oak.plugins.observation.filter.FilterProvider;
import org.apache.jackrabbit.oak.plugins.observation.filter.Selectors;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;

@RunWith(Parameterized.class)
// Don't run "Parallelized" as this causes tests to timeout in "weak" environments
public class ObservationTest extends AbstractRepositoryTest {
    public static final int ALL_EVENTS = NODE_ADDED | NODE_REMOVED | NODE_MOVED | PROPERTY_ADDED |
            PROPERTY_REMOVED | PROPERTY_CHANGED | PERSIST;
    private static final String TEST_NODE = "test_node";
    private static final String REFERENCEABLE_NODE = "\"referenceable\"";
    private static final String TEST_PATH = '/' + TEST_NODE;
    private static final String TEST_TYPE = "mix:test";
    public static final int TIME_OUT = 60;

    private Session observingSession;
    private ObservationManager observationManager;
    private String test_uuid;

    public ObservationTest(NodeStoreFixture fixture) {
        super(fixture);
    }

    @Before
    public void setup() throws RepositoryException {
        Session session = getAdminSession();

        NodeTypeManager ntMgr = session.getWorkspace().getNodeTypeManager();
        NodeTypeTemplate mixTest = ntMgr.createNodeTypeTemplate();
        mixTest.setName(TEST_TYPE);
        mixTest.setMixin(true);
        ntMgr.registerNodeType(mixTest, false);

        Node n = session.getRootNode().addNode(TEST_NODE);
        n.setProperty("test_property1", 42);
        n.setProperty("test_property2", "forty_two");
        n.addMixin(TEST_TYPE);
        Node refNode = n.addNode(REFERENCEABLE_NODE);
        refNode.addMixin(JcrConstants.MIX_REFERENCEABLE);
        test_uuid = refNode.getProperty(JcrConstants.JCR_UUID).getString();

        session.save();

        observingSession = createAdminSession();
        observationManager = observingSession.getWorkspace().getObservationManager();
    }

    @After
    public void tearDown() {
        if (observingSession != null) {
            observingSession.logout();
        }
    }

    @Test
    public void observation() throws RepositoryException, ExecutionException, InterruptedException {
        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, ALL_EVENTS, "/", true, null, null, false);
        try {
            Node n = getNode(TEST_PATH);
            listener.expectAdd(n.setProperty("p0", "v0"));
            Node n1 = listener.expectAdd(n.addNode("n1"));
            listener.expectAdd(n1.setProperty("p1", "v1"));
            listener.expectAdd(n1.setProperty("p2", "v2"));
            listener.expectAdd(n.addNode("n2"));
            getAdminSession().save();

            List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
            assertTrue("Missing events: " + missing, missing.isEmpty());
            List<Event> unexpected = listener.getUnexpected();
            assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());

            listener.expectAdd(n.setProperty("property", 42));
            Node n3 = listener.expectAdd(n.addNode("n3"));
            listener.expectAdd(n3.setProperty("p3", "v3"));
            listener.expectChange(n1.setProperty("p1", "v1.1"));
            listener.expectRemove(n1.getProperty("p2")).remove();
            listener.expectRemove(n.getNode("n2")).remove();
            listener.expectAdd(n.addNode("{4}"));
            getAdminSession().save();

            missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
            assertTrue("Missing events: " + missing, missing.isEmpty());
            unexpected = listener.getUnexpected();
            assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        }
        finally {
            observationManager.removeEventListener(listener);
        }
    }

    @Test
    public void infoMap() throws RepositoryException, ExecutionException, InterruptedException {
        Node n = getNode(TEST_PATH);
        Node n3 = n.addNode("n3");
        n3.setProperty("p1", "q1");
        n3.setProperty("p2", "q2");
        getAdminSession().save();

        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, ALL_EVENTS, "/", true, null, null, false);
        try {
            n.addNode("n1", "oak:Unstructured");
            n.addNode("n2");
            n.getNode("n2").addMixin(TEST_TYPE);
            n3.setProperty("p1", "changed");
            n3.setProperty("p2", (String) null);

            listener.expect(new Expectation("infoMap for n1") {
                @Override
                public boolean onEvent(Event event) throws Exception {
                    if (event.getType() == NODE_ADDED && event.getPath().endsWith("n1")) {
                        Map<?, ?> info = event.getInfo();
                        return info != null &&
                                "oak:Unstructured".equals(info.get(JCR_PRIMARYTYPE));
                    } else {
                        return false;
                    }
                }
            });
            listener.expect(new Expectation("infoMap for n1/jcr:primaryType") {
                @Override
                public boolean onEvent(Event event) throws Exception {
                    if (event.getType() == PROPERTY_ADDED &&
                            event.getPath().endsWith("n1/jcr:primaryType")) {
                        Map<?, ?> info = event.getInfo();
                        return info != null &&
                                "oak:Unstructured".equals(info.get(JCR_PRIMARYTYPE));
                    } else {
                        return false;
                    }
                }
            });

            listener.expect(new Expectation("infoMap for n2") {
                @Override
                public boolean onEvent(Event event) throws Exception {
                    if (event.getType() == NODE_ADDED && event.getPath().endsWith("n2")) {
                        Map<?, ?> info = event.getInfo();
                        if (info == null) {
                            return false;
                        }
                        Object mixinTypes = info.get(JCR_MIXINTYPES);
                        if (!(mixinTypes instanceof String[])) {
                            return false;
                        }

                        Object primaryType = info.get(JCR_PRIMARYTYPE);
                        String[] mixins = (String[]) mixinTypes;
                        return NT_UNSTRUCTURED.equals(primaryType) &&
                                mixins.length == 1 &&
                                TEST_TYPE.equals(mixins[0]);
                    } else {
                        return false;
                    }
                }
            });
            listener.expect(new Expectation("n2/jcr:primaryType") {
                @Override
                public boolean onEvent(Event event) throws Exception {
                    return event.getType() == PROPERTY_ADDED &&
                            event.getPath().endsWith("n2/jcr:primaryType");
                }
            });
            listener.expect(new Expectation("n2/jcr:mixinTypes") {
                @Override
                public boolean onEvent(Event event) throws Exception {
                    return event.getType() == PROPERTY_ADDED &&
                            event.getPath().endsWith("n2/jcr:mixinTypes");
                }
            });

            listener.expect(new Expectation("infoMap for n3/p1") {
                @Override
                public boolean onEvent(Event event) throws Exception {
                    if (event.getType() == PROPERTY_CHANGED &&
                            event.getPath().endsWith("n3/p1")) {
                        Map<?, ?> info = event.getInfo();
                        return info != null &&
                                NT_UNSTRUCTURED.equals(info.get(JCR_PRIMARYTYPE));
                    } else {
                        return false;
                    }
                }
            });
            listener.expect(new Expectation("infoMap for n3/p2") {
                @Override
                public boolean onEvent(Event event) throws Exception {
                    if (event.getType() == PROPERTY_REMOVED &&
                            event.getPath().endsWith("n3/p2")) {
                        Map<?, ?> info = event.getInfo();
                        return info != null &&
                                NT_UNSTRUCTURED.equals(info.get(JCR_PRIMARYTYPE));
                    } else {
                        return false;
                    }
                }
            });

            getAdminSession().save();

            List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
            assertTrue("Missing events: " + missing, missing.isEmpty());
            List<Event> unexpected = listener.getUnexpected();
            assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        }
        finally {
            observationManager.removeEventListener(listener);
        }
    }

    @Test
    public void observation2() throws RepositoryException, InterruptedException, ExecutionException {
        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, ALL_EVENTS, "/", true, null, null, false);
        try {
            Node n = getNode(TEST_PATH);
            listener.expectAdd(n.addNode("n1"));
            getAdminSession().save();

            List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
            assertTrue("Missing events: " + missing, missing.isEmpty());
            List<Event> unexpected = listener.getUnexpected();
            assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());

            listener.expectAdd(n.addNode("n2"));
            listener.expectRemove(n.getNode("n1")).remove();
            getAdminSession().save();

            missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
            assertTrue("Missing events: " + missing, missing.isEmpty());
            unexpected = listener.getUnexpected();
            assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        }
        finally {
            observationManager.removeEventListener(listener);
        }
    }

    @Test
    public void typeFilter() throws RepositoryException, InterruptedException, ExecutionException {
        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, ALL_EVENTS, "/", true, null,
                new String[]{TEST_TYPE}, false);

        try {
            Node n = getNode(TEST_PATH);
            Property p = n.setProperty("p", "v");
            listener.expectAdd(p);
            Node n1 = n.addNode("n1");
            listener.expect(n1.getPath(), NODE_ADDED);
            n1.addNode("n2");
            getAdminSession().save();

            List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
            assertTrue("Missing events: " + missing, missing.isEmpty());
            List<Event> unexpected = listener.getUnexpected();
            assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());

            listener.expectChange(p).setValue("v2");
            getAdminSession().save();

            missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
            assertTrue("Missing events: " + missing, missing.isEmpty());
            unexpected = listener.getUnexpected();
            assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());

            listener.expectRemove(p).remove();
            getAdminSession().save();

            missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
            assertTrue("Missing events: " + missing, missing.isEmpty());
            unexpected = listener.getUnexpected();
            assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        }
        finally {
            observationManager.removeEventListener(listener);
        }
    }

    @Test
    public void uuidFilter() throws RepositoryException, InterruptedException, ExecutionException {
        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, ALL_EVENTS, "/", true,
                new String[]{test_uuid}, null, false);

        try {
            Node nonRefNode = getNode(TEST_PATH);
            Node refNode = nonRefNode.getNode(REFERENCEABLE_NODE);

            nonRefNode.addNode("n");
            listener.expect(refNode.addNode("r").getPath(), NODE_ADDED);
            getAdminSession().save();

            List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
            assertTrue("Missing events: " + missing, missing.isEmpty());
            List<Event> unexpected = listener.getUnexpected();
            assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        }
        finally {
            observationManager.removeEventListener(listener);
        }
    }

    @Test
    public void identifier() throws RepositoryException, InterruptedException, ExecutionException {
        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, NODE_ADDED, TEST_PATH, true, null, null, false);
        try {
            Node n = getNode(TEST_PATH);
            listener.expect(new Expectation("Has correct id") {
                @Override
                public boolean onEvent(Event event) throws Exception {
                    return (TEST_PATH + "/newNode").equals(event.getIdentifier());
                }
            });

            n.addNode("newNode");
            getAdminSession().save();

            List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
            assertTrue("Missing events: " + missing, missing.isEmpty());
            List<Event> unexpected = listener.getUnexpected();
            assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        }
        finally {
            observationManager.removeEventListener(listener);
        }
    }

    @Test
    public void observationOnRootNode() throws Exception {
        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, PROPERTY_ADDED, "/", true, null, null, false);
        try {
            // add property to root node
            Node root = getNode("/");
            listener.expectAdd(root.setProperty("prop", "value"));
            root.getSession().save();

            List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
            assertTrue("Missing events: " + missing, missing.isEmpty());
            List<Event> unexpected = listener.getUnexpected();
            assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        }
        finally {
            observationManager.removeEventListener(listener);
        }
    }
    
    @Test
    public void propertyFilter() throws Exception {
        Node root = getNode("/");
        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, PROPERTY_ADDED, "/a/b", false, null, null, false);
        Node a = root.addNode("a");
        Node b = a.addNode("b");
        listener.expect("/a/b/jcr:primaryType", PROPERTY_ADDED);

        listener.expectAdd(b.setProperty("propName", 1));
    	root.getSession().save();

    	List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }
    
    @Test
    public void pathFilter() throws Exception {
        final String path = "/events/only/here";

        for (boolean deep : new boolean[]{false, true}) {
            Node root = getNode("/");
            if (root.hasNode("events")) {
                root.getNode("events").remove();
                root.getSession().save();
            }

            ExpectationListener listener = new ExpectationListener();
            observationManager.addEventListener(listener, NODE_ADDED, path, deep, null, null, false);
            try {
                root.addNode("events").addNode("only").addNode("here").addNode("below").addNode("this");
                listener.expect("/events/only/here/below", NODE_ADDED);
                if (deep) {
                    listener.expect("/events/only/here/below/this", NODE_ADDED);
                }
                root.getSession().save();

                List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
                assertTrue("Missing events: " + missing, missing.isEmpty());
                List<Event> unexpected = listener.getUnexpected();
                assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
            }
            finally {
                observationManager.removeEventListener(listener);
            }
        }
    }

    @Test
    public void pathFilterWithTrailingSlash() throws Exception {
        final String path = "/events/only/here";
        ExpectationListener listener = new ExpectationListener();
        listener.expect(new Expectation(path){
            @Override
            public boolean onEvent(Event event) throws Exception {
                return PathUtils.isAncestor(path, event.getPath());
            }
        });

        observationManager.addEventListener(listener, NODE_ADDED, path + '/', true, null, null, false);
        try {
            Node root = getNode("/");
            root.addNode("events").addNode("only").addNode("here").addNode("at");
            root.getSession().save();

            List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
            assertTrue("Missing events: " + missing, missing.isEmpty());
            List<Event> unexpected = listener.getUnexpected();
            assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        }
        finally {
            observationManager.removeEventListener(listener);
        }
    }

    @Test
    public void observationDispose()
            throws RepositoryException, InterruptedException, ExecutionException, TimeoutException {

        final ExpectationListener listener = new ExpectationListener();
        Expectation hasEvents = listener.expect(
                new Expectation("has events after registering"));
        final Expectation noEvents = listener.expect(
                new Expectation("has no more events after unregistering", false));

        observationManager.addEventListener(listener, ALL_EVENTS, "/", true, null, null, false);

        final Session s = getAdminSession();
        // Generate events
        ScheduledExecutorService service = newSingleThreadScheduledExecutor();
        service.scheduleWithFixedDelay(new Runnable() {
            private int c;

            @Override
            public void run() {
                try {
                    s.getNode(TEST_PATH)
                            .addNode("c" + c++)
                            .getSession()
                            .save();
                }
                catch (RepositoryException e) {
                    throw new RuntimeException(e);
                }
            }
        }, 10, 10, TimeUnit.MILLISECONDS);

        // Make sure we see the events
        assertNotNull(hasEvents.get(TIME_OUT, TimeUnit.SECONDS));

        // Remove event listener
        Executors.newSingleThreadExecutor().submit(new Callable<Void>() {
            @Override
            public Void call() throws Exception {
                observationManager.removeEventListener(listener);
                noEvents.enable(true);
                return null;
            }
        }).get(10, TimeUnit.SECONDS);

        // Make sure we see no more events
        assertFalse(noEvents.wait(4, TimeUnit.SECONDS));

        service.shutdown();
        service.awaitTermination(10, TimeUnit.SECONDS);
    }

    @Test
    public void observationDisposeFromListener()
            throws RepositoryException, InterruptedException, ExecutionException, TimeoutException {

        final ExpectationListener listener = new ExpectationListener();
        Expectation unregistered = listener.expect(new Expectation
                ("Unregistering listener from event handler should not block") {
            @Override
            public boolean onEvent(Event event) throws Exception {
                observationManager.removeEventListener(listener);
                return true;
            }
        });

        observationManager.addEventListener(listener, ALL_EVENTS, "/", true, null, null, false);

        // Ensure the listener is there
        assertTrue(observationManager.getRegisteredEventListeners().hasNext());

        // Generate events
        Node n = getNode(TEST_PATH);
        n.addNode("c");
        n.getSession().save();

        // Make sure we see the events and the listener is gone
        assertNotNull(unregistered.get(TIME_OUT, TimeUnit.SECONDS));
        assertFalse(observationManager.getRegisteredEventListeners().hasNext());
    }

    @Test
    public void testMove() throws RepositoryException, ExecutionException, InterruptedException {
        Node testNode = getNode(TEST_PATH);
        Session session = testNode.getSession();
        Node nodeA = testNode.addNode("a");
        Node nodeAA = nodeA.addNode("aa");
        Node nodeT = testNode.addNode("t");
        Node nodeS = testNode.addNode("s");
        session.save();

        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, NODE_MOVED, "/", true, null, null, false);

        String src1 = nodeA.getPath();
        String dst1 = nodeT.getPath() + "/b";
        session.move(src1, dst1);
        listener.expectMove(src1, dst1);

        String src2 = nodeT.getPath() + "/b/aa";
        String dst2 = nodeS.getPath() + "/bb";
        session.move(src2, dst2);
        listener.expectMove(src1 + "/aa", dst2);

        session.save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void testRename() throws RepositoryException, ExecutionException, InterruptedException {
        Node testNode = getNode(TEST_PATH);
        Session session = testNode.getSession();
        Node nodeA = testNode.addNode("a");
        String parentPath = testNode.getPath();
        session.save();
        assumeTrue(nodeA instanceof JackrabbitNode);

        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, NODE_MOVED, "/", true, null, null, false);

        ((JackrabbitNode) nodeA).rename("b");
        listener.expectMove(parentPath + "/a", parentPath + "/b");

        session.save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void testReorder() throws RepositoryException, InterruptedException, ExecutionException {
        Node testNode = getNode(TEST_PATH);
        Node nodeA = testNode.addNode("a", "nt:unstructured");
        Node nodeB = testNode.addNode("b", "nt:unstructured");
        testNode.getSession().save();

        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, NODE_MOVED, "/", true, null, null, false);
        listener.expect(new Expectation("orderBefore") {
            @Override
            public boolean onEvent(Event event) throws Exception {
                if (event.getType() != NODE_MOVED || event.getInfo() == null) {
                    return false;
                }

                Map<?, ?> info = event.getInfo();
                if (PathUtils.concat(TEST_PATH, "a").equals(event.getPath())) {
                    return "a".equals(info.get("srcChildRelPath")) &&
                            "b".equals(info.get("destChildRelPath"));
                } else if (PathUtils.concat(TEST_PATH, "b").equals(event.getPath())) {
                    return "b".equals(info.get("srcChildRelPath")) &&
                            "a".equals(info.get("destChildRelPath"));
                } else {
                    return false;
                }
            }
        });

        testNode.orderBefore(nodeA.getName(), null);
        testNode.getSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void addSubtree() throws RepositoryException, ExecutionException, InterruptedException {
        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, ALL_EVENTS, "/", true, null, null, false);

        Node n = getNode(TEST_PATH);
        Node a = listener.expectAdd(n.addNode("a"));
        Node b = listener.expectAdd(a.addNode("b"));
        listener.expectAdd(b.addNode("c"));
        getAdminSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void removeSubtree() throws RepositoryException, ExecutionException, InterruptedException {
        Node n = getNode(TEST_PATH);
        n.addNode("a").addNode("b").addNode("c");
        getAdminSession().save();

        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, ALL_EVENTS, "/", true, null, null, false);

        listener.expectRemove(n.getNode("a")).remove();
        getAdminSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void moveSubtree() throws RepositoryException, ExecutionException, InterruptedException {
        Node n = getNode(TEST_PATH);
        n.addNode("a").addNode("b").addNode("c");
        getAdminSession().save();

        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, ALL_EVENTS, "/", true, null, null, false);

        getAdminSession().move(TEST_PATH + "/a", TEST_PATH + "/t");
        listener.expect(TEST_PATH + "/t", NODE_MOVED);
        listener.expect(TEST_PATH + "/a", NODE_REMOVED);
        listener.expect(TEST_PATH + "/a/jcr:primaryType", PROPERTY_REMOVED);
        listener.expect(TEST_PATH + "/t", NODE_ADDED);
        getAdminSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void filterDisjunctPaths()
            throws ExecutionException, InterruptedException, RepositoryException {
        assumeTrue(observationManager instanceof JackrabbitObservationManager);
        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        FilterBuilder builder = new FilterBuilder();
        builder.condition(builder.any(
                builder.path(TEST_PATH + "/a/b"),
                builder.path(TEST_PATH + "/x/y")));
        oManager.addEventListener(listener, builder.build());

        Node testNode = getNode(TEST_PATH);
        Node b = testNode.addNode("a").addNode("b");
        b.addNode("c");
        Node y = testNode.addNode("x").addNode("y");
        y.addNode("z");

        listener.expect(b.getPath(), NODE_ADDED);
        listener.expect(y.getPath(), NODE_ADDED);
        testNode.getSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void disjunctPaths() throws ExecutionException, InterruptedException, RepositoryException {
        assumeTrue(observationManager instanceof JackrabbitObservationManager);
        JackrabbitObservationManager oManager = (JackrabbitObservationManager) observationManager;
        ExpectationListener listener = new ExpectationListener();
        JackrabbitEventFilter filter = new JackrabbitEventFilter()
                .setAdditionalPaths(TEST_PATH + "/a", TEST_PATH + "/x")
                .setEventTypes(NODE_ADDED);
        oManager.addEventListener(listener, filter);

        Node testNode = getNode(TEST_PATH);
        Node b = testNode.addNode("a").addNode("b");
        b.addNode("c");
        Node y = testNode.addNode("x").addNode("y");
        y.addNode("z");

        listener.expect(b.getPath(), NODE_ADDED);
        listener.expect(y.getPath(), NODE_ADDED);
        testNode.getSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void noDuplicates() throws ExecutionException, InterruptedException, RepositoryException {
        assumeTrue(observationManager instanceof JackrabbitObservationManager);
        JackrabbitObservationManager oManager = (JackrabbitObservationManager) observationManager;
        ExpectationListener listener = new ExpectationListener();
        JackrabbitEventFilter filter = new JackrabbitEventFilter()
                .setAdditionalPaths(TEST_PATH + "/a", TEST_PATH + "/a")
                .setEventTypes(NODE_ADDED);
        oManager.addEventListener(listener, filter);

        Node testNode = getNode(TEST_PATH);
        Node b = testNode.addNode("a").addNode("b");
        b.addNode("c");
        Node y = testNode.addNode("x").addNode("y");
        y.addNode("z");

        listener.expect(b.getPath(), NODE_ADDED);
        testNode.getSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void pathExclude() throws ExecutionException, InterruptedException, RepositoryException {
        assumeTrue(observationManager instanceof JackrabbitObservationManager);
        JackrabbitObservationManager oManager = (JackrabbitObservationManager) observationManager;
        ExpectationListener listener = new ExpectationListener();
        JackrabbitEventFilter filter = new JackrabbitEventFilter()
                .setAbsPath(TEST_PATH)
                .setIsDeep(true)
                .setExcludedPaths(TEST_PATH + "/c", TEST_PATH + "/d",  "/x/y")
                .setEventTypes(ALL_EVENTS);
        oManager.addEventListener(listener, filter);

        Node n = getNode(TEST_PATH);
        listener.expectAdd(listener.expectAdd(
                listener.expectAdd(n.addNode("a")).addNode("a1")).setProperty("p", "q"));
        listener.expectAdd(
                listener.expectAdd(n.addNode("b")).setProperty("p", "q"));
        n.addNode("c").addNode("c1").setProperty("p", "q");
        n.addNode("d").setProperty("p", "q");
        getAdminSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void excludePathInvalid() {
        assumeTrue(observationManager instanceof JackrabbitObservationManager);
        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        String invalidPath = TEST_PATH + "/[n]";
        JackrabbitEventFilter filter = new JackrabbitEventFilter()
                .setAbsPath(TEST_PATH)
                .setIsDeep(true)
                .setExcludedPaths(TEST_PATH + "/c", invalidPath)
                .setEventTypes(ALL_EVENTS);
        try {
            oManager.addEventListener(listener, filter);
            fail("addEventListener() must fail with RepositoryException");
        } catch (RepositoryException e) {
            // expected
            assertThat(e.getMessage(), containsString("exclude"));
            assertThat(e.getMessage(), containsString(invalidPath));
        } catch (Exception e) {
            fail("Unexpected exception: " + e);
        }
    }

    @Test
    public void includePathInvalid() {
        assumeTrue(observationManager instanceof JackrabbitObservationManager);
        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        String invalidPath = TEST_PATH + "/[n]";
        JackrabbitEventFilter filter = new JackrabbitEventFilter()
                .setAbsPath(TEST_PATH)
                .setAdditionalPaths(invalidPath)
                .setIsDeep(true)
                .setEventTypes(ALL_EVENTS);
        try {
            oManager.addEventListener(listener, filter);
            fail("addEventListener() must fail with RepositoryException");
        } catch (RepositoryException e) {
            // expected
            assertThat(e.getMessage(), containsString("include"));
            assertThat(e.getMessage(), containsString(invalidPath));
        } catch (Exception e) {
            fail("Unexpected exception: " + e);
        }
    }

    @Test
    public void parentPathExclude() throws ExecutionException, InterruptedException, RepositoryException {
        assumeTrue(observationManager instanceof JackrabbitObservationManager);

        Node n = getNode(TEST_PATH).addNode("n");
        getAdminSession().save();

        JackrabbitObservationManager oManager = (JackrabbitObservationManager) observationManager;
        ExpectationListener listener = new ExpectationListener();
        JackrabbitEventFilter filter = new JackrabbitEventFilter()
                .setAbsPath(n.getPath())
                .setIsDeep(true)
                .setExcludedPaths(n.getParent().getPath())
                .setEventTypes(ALL_EVENTS);
        oManager.addEventListener(listener, filter);

        n.addNode("n1");
        getAdminSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }
    
    @Test
    public void deepNodeTypeMixinHierarchy() throws Exception {
        NodeTypeManager ntm = getAdminSession().getWorkspace().getNodeTypeManager();
        NodeTypeTemplate parentMixin = ntm.createNodeTypeTemplate();
        parentMixin.setName("parentmixin");
        parentMixin.setMixin(true);
        ntm.registerNodeType(parentMixin, false);
        NodeTypeTemplate childMixin = ntm.createNodeTypeTemplate();
        childMixin.setName("childmixin");
        childMixin.setMixin(true);
        childMixin.setDeclaredSuperTypeNames(new String[] {"parentmixin"});
        ntm.registerNodeType(childMixin, false);
        NodeTypeTemplate mytype = ntm.createNodeTypeTemplate();
        mytype.setName("mytype");
        mytype.setMixin(false);
        mytype.setDeclaredSuperTypeNames(new String[] {"childmixin"});
        NodeDefinitionTemplate child = ntm.createNodeDefinitionTemplate();
        child.setName("*");
        child.setDefaultPrimaryTypeName("nt:base");
        child.setRequiredPrimaryTypeNames(new String[] {"nt:base"});
        List<NodeDefinition> children = mytype.getNodeDefinitionTemplates();
        children.add(child);
        ntm.registerNodeType(mytype, false);
        getAdminSession().save();
        
        // create a fresh session here to catch the above new node type definitions
        observingSession = createAdminSession();
        observationManager = observingSession.getWorkspace().getObservationManager();
        JackrabbitObservationManager oManager = (JackrabbitObservationManager) observationManager;
        ExpectationListener listener = new ExpectationListener();
        JackrabbitEventFilter filter = new JackrabbitEventFilter()
                .setAbsPath("/")
                .setIsDeep(true)
                .setNodeTypes(new String[] {"parentmixin"})
                .setEventTypes(ALL_EVENTS);
        oManager.addEventListener(listener, filter);

        Node n = getNode(TEST_PATH).addNode("n", "mytype");
        listener.expect(n.getPath() + "/jcr:primaryType", PROPERTY_ADDED);
        Node m = n.addNode("m", "nt:unstructured");
        listener.expect(m.getPath(), NODE_ADDED);
        getAdminSession().save();

        Thread.sleep(1000);
        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void filterPropertyOfParent()
            throws RepositoryException, ExecutionException, InterruptedException {
        assumeTrue(observationManager instanceof ObservationManagerImpl);
        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        FilterBuilder builder = new FilterBuilder();

        // Events for all items whose parent has a property named "foo" with value "bar"
        builder.condition(builder.property(Selectors.PARENT, "foo",
                new Predicate<PropertyState>() {
                    @Override
                    public boolean test(PropertyState property) {
                        return "bar".equals(property.getValue(STRING));
                    }
                }));
        oManager.addEventListener(listener, builder.build());

        Node testNode = getNode(TEST_PATH);
        Node a = testNode.addNode("a");
        Node x = testNode.addNode("x");
        a.setProperty("foo", "bar");
        x.setProperty("foo", "baz");
        a.addNode("b");
        x.addNode("y");

        listener.expect(a.getPath() + "/jcr:primaryType", PROPERTY_ADDED);
        listener.expect(a.getPath() + "/foo", PROPERTY_ADDED);
        listener.expect(a.getPath() + "/b", NODE_ADDED);
        testNode.getSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void filterPropertyOfChild()
            throws RepositoryException, ExecutionException, InterruptedException {
        assumeTrue(observationManager instanceof ObservationManagerImpl);
        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        FilterBuilder builder = new FilterBuilder();

        // Events for all items that have a property "b/c/foo" with value "bar"
        builder.condition(builder.property(Selectors.fromThis("b/c"), "foo",
                new Predicate<PropertyState>() {
                    @Override
                    public boolean test(PropertyState property) {
                        return "bar".equals(property.getValue(STRING));
                    }
                }));
        oManager.addEventListener(listener, builder.build());

        Node testNode = getNode(TEST_PATH);
        Node a = testNode.addNode("a");
        a.addNode("b").addNode("c").setProperty("foo", "bar");
        a.addNode("d");
        Node x = testNode.addNode("x");
        x.addNode("b").addNode("c").setProperty("foo", "baz");
        x.addNode("d");

        listener.expect(a.getPath(), NODE_ADDED);
        testNode.getSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void addSubtreeFilter() throws RepositoryException, ExecutionException, InterruptedException {
        assumeTrue(observationManager instanceof ObservationManagerImpl);
        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        FilterBuilder builder = new FilterBuilder();

        // Only generate events for the root of added sub trees
        builder.condition(builder.addSubtree());
        oManager.addEventListener(listener, builder.build());

        Node testNode = getNode(TEST_PATH);
        Node a = listener.expectAdd(testNode.addNode("a"));
        a.addNode("c");
        testNode.getSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void removeSubtreeFilter() throws RepositoryException, ExecutionException, InterruptedException {
        assumeTrue(observationManager instanceof ObservationManagerImpl);

        Node testNode = getNode(TEST_PATH);
        testNode.addNode("a").addNode("c");
        testNode.getSession().save();

        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        FilterBuilder builder = new FilterBuilder();

        // Only generate events for the root of deleted sub trees
        builder.condition(builder.deleteSubtree());
        oManager.addEventListener(listener, builder.build());

        listener.expectRemove(testNode.getNode("a")).remove();
        testNode.getSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void propertyValue() throws RepositoryException, ExecutionException, InterruptedException {
        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, ALL_EVENTS, "/", true, null, null, false);
        try {
            Node n = getNode(TEST_PATH);
            n.setProperty("added", 42);
            listener.expectValue(null, n.getProperty("added").getValue());

            Value before = n.getProperty("test_property1").getValue();
            n.getProperty("test_property1").setValue(43);
            listener.expectValue(before, n.getProperty("test_property1").getValue());

            before = n.getProperty("test_property2").getValue();
            n.getProperty("test_property2").remove();
            listener.expectValue(before, null);
            getAdminSession().save();

            List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
            assertTrue("Missing events: " + missing, missing.isEmpty());
            List<Event> unexpected = listener.getUnexpected();
            assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        }
        finally {
            observationManager.removeEventListener(listener);
        }
    }

    @Test
    public void propertyValues() throws RepositoryException, ExecutionException, InterruptedException {
        Node n = getNode(TEST_PATH);
        n.setProperty("toChange", new String[]{"one", "two"}, PropertyType.STRING);
        n.setProperty("toDelete", new String[]{"three", "four"}, PropertyType.STRING);
        getAdminSession().save();

        ExpectationListener listener = new ExpectationListener();
        observationManager.addEventListener(listener, ALL_EVENTS, "/", true, null, null, false);
        try {
            n.setProperty("added", new String[]{"five", "six"}, PropertyType.STRING);
            listener.expectValues(null, n.getProperty("added").getValues());

            Value[] before = n.getProperty("toChange").getValues();
            n.getProperty("toChange").setValue(new String[]{"1", "2"});
            listener.expectValues(before, n.getProperty("toChange").getValues());

            before = n.getProperty("toDelete").getValues();
            n.getProperty("toDelete").remove();
            listener.expectValues(before, null);
            getAdminSession().save();

            List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
            assertTrue("Missing events: " + missing, missing.isEmpty());
            List<Event> unexpected = listener.getUnexpected();
            assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        }
        finally {
            observationManager.removeEventListener(listener);
        }
    }

    //------------------------------------------------------------< private >---

    private Node getNode(String path) throws RepositoryException {
        return getAdminSession().getNode(path);
    }

    //------------------------------------------------------------< ExpectationListener >---

    private static class Expectation extends ForwardingListenableFuture<Event> {
        private final SettableFuture<Event> future = SettableFuture.create();
        private final String name;

        private volatile boolean enabled = true;

        Expectation(String name, boolean enabled) {
            this.name = name;
            this.enabled = enabled;
        }

        Expectation(String name) {
            this(name, true);
        }

        @Override
        protected ListenableFuture<Event> delegate() {
            return future;
        }

        public void enable(boolean enabled) {
            this.enabled = enabled;
        }

        public boolean isEnabled() {
            return enabled;
        }

        public void complete(Event event) {
            future.set(event);
        }

        public boolean isComplete() {
            return future.isDone();
        }

        public void fail(Exception e) {
            future.setException(e);
        }

        public boolean wait(long timeout, TimeUnit unit) {
            try {
                future.get(timeout, unit);
                return true;
            }
            catch (Exception e) {
                return false;
            }
        }

        public boolean onEvent(Event event) throws Exception {
            return true;
        }

        @Override
        public String toString() {
            return name;
        }
    }

    private static class ExpectationListener implements EventListener {
        private final Set<Expectation> expected = synchronizedSet(new CopyOnWriteArraySet<>());
        private final Set<Expectation> optional = synchronizedSet(new CopyOnWriteArraySet<>());
        private final List<Event> unexpected = synchronizedList(new CopyOnWriteArrayList<>());

        private volatile Exception failed;

        public Expectation expect(Expectation expectation) {
            if (failed != null) {
                expectation.fail(failed);
            }
            expected.add(expectation);
            return expectation;
        }

        public Expectation optional(Expectation expectation) {
            if (failed != null) {
                expectation.fail(failed);
            }
            optional.add(expectation);
            return expectation;
        }

        public Future<Event> expect(final String path, final int type) {
            return expect(new Expectation("path = " + path + ", type = " + type) {
                @Override
                public boolean onEvent(Event event) throws RepositoryException {
                    return type == event.getType() && Objects.equals(path, event.getPath());
                }
            });
        }

        public Future<Event> expect(final String path, final String identifier, final int type) {
            return expect(new Expectation("path = " + path + ", identifier = " + identifier + ", type = " + type) {
                @Override
                public boolean onEvent(Event event) throws RepositoryException {
                    return type == event.getType() && Objects.equals(path, event.getPath()) && Objects.equals(identifier, event.getIdentifier());
                }
            });
        }

        public Node expectAdd(Node node) throws RepositoryException {
            expect(node.getPath(), NODE_ADDED);
            expect(node.getPath() + "/jcr:primaryType", PROPERTY_ADDED);
            return node;
        }

        public Node expectRemove(Node node) throws RepositoryException {
            expect(node.getPath(), NODE_REMOVED);
            expect(node.getPath() + "/jcr:primaryType", PROPERTY_REMOVED);
            return node;
        }

        public Property expectAdd(Property property) throws RepositoryException {
            expect(property.getPath(), PROPERTY_ADDED);
            return property;
        }

        public Property expectRemove(Property property) throws RepositoryException {
            expect(property.getPath(), PROPERTY_REMOVED);
            return property;
        }

        public Property expectChange(Property property) throws RepositoryException {
            expect(property.getPath(), PROPERTY_CHANGED);
            return property;
        }

        public void expectMove(final String src, final String dst) {
            expect(new Expectation('>' + src + ':' + dst){
                @Override
                public boolean onEvent(Event event) throws Exception {
                    return event.getType() == NODE_MOVED &&
                            Objects.equals(dst, event.getPath()) &&
                            Objects.equals(src, event.getInfo().get("srcAbsPath")) &&
                            Objects.equals(dst, event.getInfo().get("destAbsPath"));
                }
            });
        }

        public void expectValue(final Value before, final Value after) {
            expect(new Expectation("Before value " + before + " after value " + after) {
                @Override
                public boolean onEvent(Event event) throws Exception {
                    return Objects.equals(before, event.getInfo().get("beforeValue")) &&
                            Objects.equals(after, event.getInfo().get("afterValue"));
                }
            });
        }

        public void expectValues(final Value[] before, final Value[] after) {
            expect(new Expectation("Before valuse " + before + " after values " + after) {
                @Override
                public boolean onEvent(Event event) throws Exception {
                    return Arrays.equals(before, (Object[])event.getInfo().get("beforeValue")) &&
                           Arrays.equals(after, (Object[]) event.getInfo().get("afterValue"));
                }
            });
        }

        public Future<Event> expectBeforeValue(final String path, final int type, final String beforeValue) {
            return expect(new Expectation("path = " + path + ", type = " + type + ", beforeValue = " + beforeValue) {
                @Override
                public boolean onEvent(Event event) throws RepositoryException {
                    return type == event.getType() && Objects.equals(path, event.getPath()) && event.getInfo().containsKey("beforeValue") && beforeValue.equals(((Value)event.getInfo().get("beforeValue")).getString());
                }
            });
        }

        public List<Expectation> getMissing(int time, TimeUnit timeUnit)
                throws ExecutionException, InterruptedException {
            List<Expectation> missing = new ArrayList<>();
            long t0 = System.nanoTime();
            try {
                Futures.allAsList(expected).get(time, timeUnit);
            }
            catch (TimeoutException e) {
                for (Expectation exp : expected) {
                    if (!exp.isDone()) {
                        missing.add(exp);
                    }
                }
            }
            return missing;
        }

        public List<Event> getUnexpected() {
            return new ArrayList<>(unexpected);
        }

        @Override
        public void onEvent(EventIterator events) {
            try {
                while (events.hasNext() && failed == null) {
                    Event event = events.nextEvent();
                    boolean found = false;
                    for (Expectation exp : expected) {
                        if (exp.isEnabled() && !exp.isComplete() && exp.onEvent(event)) {
                            found = true;
                            exp.complete(event);
                        }
                    }
                    for (Expectation opt : optional) {
                        if (opt.isEnabled() && !opt.isComplete() && opt.onEvent(event)) {
                            found = true;
                            opt.complete(event);
                        }
                    }
                    if (!found) {
                        unexpected.add(event);
                    }

                }
            } catch (Exception e) {
                for (Expectation exp : expected) {
                    exp.fail(e);
                }
                failed = e;
            }
        }

        private static String key(String path, int type) {
            return path + ':' + type;
        }
    }
    
    //------------------------------------------------------------< OakEventFilter tests >---

    @Test
    public void applyNodeTypeOnSelf() throws Exception {
        assumeTrue(observationManager instanceof ObservationManagerImpl);

        Node testNode = getNode(TEST_PATH);
        testNode.addNode("a", "nt:unstructured").addNode("b", "oak:Unstructured").addNode("c", "nt:unstructured");
        testNode.getSession().save();

        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        
        JackrabbitEventFilter filter = new JackrabbitEventFilter();
        filter.setEventTypes(ALL_EVENTS);
        filter.setAbsPath("/");
        filter.setIsDeep(true);
        filter.setNodeTypes(new String[] {"oak:Unstructured"});
        filter = FilterFactory.wrap(filter).withApplyNodeTypeOnSelf();

        oManager.addEventListener(listener, filter);

        testNode.getNode("a").getNode("b").getNode("c").remove();
        testNode.getSession().save();

        // wait 1 sec to give failures a chance (we're not expecting anything, but perhaps
        // something would come, after 1sec more likely than after 0sec)
        Thread.sleep(1000);
        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());

        Node b = testNode.getNode("a").getNode("b");
        // OAK-5061 : the event NODE_REMOVED on /a/b is actually expected and was missing in the test:
        listener.expect(b.getPath(), NODE_REMOVED);
        b.remove();
        testNode.getSession().save();

        Thread.sleep(1000);
        missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    
    }
    
    @Test
    public void includeAncestorsRemove_WithGlobs() throws Exception {
        OakEventFilter oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeGlobPaths(TEST_PATH + "/a/b/c/d.jsp");
        oef.setIsDeep(true);
        oef.withIncludeAncestorsRemove();
        doIncludeAncestorsRemove_WithGlobs(oef);

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeGlobPaths(TEST_PATH + "/a/b/*/d.jsp");
        oef.setIsDeep(true);
        oef.withIncludeAncestorsRemove();
        doIncludeAncestorsRemove_WithGlobs(oef);

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeGlobPaths(TEST_PATH + "/a/*/*/d.jsp");
        oef.setIsDeep(true);
        oef.withIncludeAncestorsRemove();
        doIncludeAncestorsRemove_WithGlobs(oef);

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeGlobPaths(TEST_PATH + "/*/b/*/d.jsp");
        oef.setIsDeep(true);
        oef.withIncludeAncestorsRemove();
        doIncludeAncestorsRemove_WithGlobs(oef);

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeGlobPaths(TEST_PATH + "/*/b/c/d.jsp");
        oef.setIsDeep(true);
        oef.withIncludeAncestorsRemove();
        doIncludeAncestorsRemove_WithGlobs(oef);

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeGlobPaths(TEST_PATH + "/*/*/c/d.jsp");
        oef.setIsDeep(true);
        oef.withIncludeAncestorsRemove();
        doIncludeAncestorsRemove_WithGlobs(oef);

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeGlobPaths(TEST_PATH + "/*/*/*/d.jsp");
        oef.setIsDeep(true);
        oef.withIncludeAncestorsRemove();
        doIncludeAncestorsRemove_WithGlobs(oef);

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeGlobPaths(TEST_PATH + "/a/**/d.jsp");
        oef.setIsDeep(true);
        oef.withIncludeAncestorsRemove();
        doIncludeAncestorsRemove_WithGlobs(oef);

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeGlobPaths(TEST_PATH + "/**/c/d.jsp");
        oef.setIsDeep(true);
        oef.withIncludeAncestorsRemove();
        doIncludeAncestorsRemove_WithGlobs(oef);

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeGlobPaths("/**/d.jsp");
        oef.setIsDeep(true);
        oef.withIncludeAncestorsRemove();
        doIncludeAncestorsRemove_WithGlobs(oef);

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeGlobPaths(TEST_PATH + "/**/d.jsp");
        oef.setIsDeep(true);
        oef.withIncludeAncestorsRemove();
        doIncludeAncestorsRemove_WithGlobs(oef);
    }
    
    void doIncludeAncestorsRemove_WithGlobs(OakEventFilter oef) throws Exception {
        Node testNode = getNode(TEST_PATH);
        testNode.addNode("a").addNode("b").addNode("c").addNode("d.jsp").setProperty("e", 42);
        testNode.getSession().save();

        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        final AtomicBoolean done = new AtomicBoolean(false);
        final AtomicBoolean unexpected = new AtomicBoolean(false);
        final AtomicBoolean failure = new AtomicBoolean(false);
        EventListener listener = new EventListener() {
            
            @Override
            public void onEvent(EventIterator events) {
                while(events.hasNext()) {
                    Event event = events.nextEvent();
                    System.out.println("got: "+event);
                    String path = "";
                    try {
                        path = event.getPath();
                    } catch (RepositoryException e) {
                        e.printStackTrace();
                        failure.set(true);
                    }
                    if (path.equals(TEST_PATH + "/a/b") && event.getType() == NODE_REMOVED) {
                        done.set(true);
                    } else if (path.equals(TEST_PATH + "/a/b/c/d.jsp") && event.getType() == NODE_REMOVED) {
                        done.set(true);
                    } else if (path.equals(TEST_PATH + "/a/b/c/d.jsp/jcr:primaryType") && event.getType() == PROPERTY_REMOVED) {
                        done.set(true);
                    } else {
                        System.out.println("Unexpected event: "+event);
                        unexpected.set(true);
                    }
                }
            }
        };
        oManager.addEventListener(listener, oef);
        
        Node b = testNode.getNode("a").getNode("b");
        b.remove();
        testNode.getSession().save();
        
        Thread.sleep(1000);
        assertTrue("didnt get either event", done.get());
        assertFalse("did get unexpected events", unexpected.get());
        assertFalse("got an exception", failure.get());
        
        oManager.removeEventListener(listener);
        testNode.getNode("a").remove();
        testNode.getSession().save();
    }
    
    /**
     * This tests a filter which registers a few paths and then expects
     * NOT to get any event if an unrelated parent is removed
     */
    @Test
    public void includeAncestorsRemove_Unrelated() throws Exception {
        doIncludeAncestorsRemove_Unrelated(
                new String[] {"/a/b/c/d"}, 
                new String[] {"/unrelated/child", "/unrelated", "/a/b/c/unrelated/child", "/a/b/c/unrelated", 
                        "/a/b/unrelated/child", "/a/b/unrelated", "/a/unrelated/child", "/a/unrelated", 
                        "/a"},
                new String[] {"/a"},
                new String[] {});
        doIncludeAncestorsRemove_Unrelated(
                new String[] {"/a/b/c/d"}, 
                new String[] {"/unrelated/child", "/unrelated", "/a/b/c/unrelated/child", "/a/b/c/unrelated", 
                        "/a/b/unrelated/child", "/a/b/unrelated", "/a/unrelated/child", "/a/unrelated", 
                        "/a/b"},
                new String[] {"/a/b"},
                new String[] {});
        doIncludeAncestorsRemove_Unrelated(
                new String[] {"/a/b/c/d"}, 
                new String[] {"/unrelated/child", "/unrelated", "/a/b/c/unrelated/child", "/a/b/c/unrelated", 
                        "/a/b/unrelated/child", "/a/b/unrelated", "/a/unrelated/child", "/a/unrelated", 
                        "/a/b/c"},
                new String[] {"/a/b/c"},
                new String[] {});
        doIncludeAncestorsRemove_Unrelated(
                new String[] {"/a/b/c/d"}, 
                new String[] {"/unrelated/child", "/unrelated", "/a/b/c/unrelated/child", "/a/b/c/unrelated", 
                        "/a/b/unrelated/child", "/a/b/unrelated", "/a/unrelated/child", "/a/unrelated", 
                        "/a/b/c/d"},
                new String[] {"/a/b/c/d"},
                new String[] {"/a/b/c/d/jcr:primaryType"});
        doIncludeAncestorsRemove_Unrelated(
                new String[] {"/a/b/c/d/*.html"}, 
                new String[] {"/unrelated/child", "/unrelated", "/a/b/c/unrelated/child", "/a/b/c/unrelated", 
                        "/a/b/unrelated/child", "/a/b/unrelated", "/a/unrelated/child", "/a/unrelated", 
                        "/a/b/c/d"},
                new String[] {"/a/b/c/d"},
                new String[] {});

        // and some glob tests:
        doIncludeAncestorsRemove_Unrelated(
                new String[] {"/a/b/*/d"}, 
                new String[] {"/unrelated/child", "/unrelated", "/a/b/c/unrelated/child", "/a/b/c/unrelated", 
                        "/a/b/related/unrelatedchild", "/a/b/related", "/a/unrelated/child", "/a/unrelated", 
                        "/a"},
                new String[] {"/a", "/a/b/related"},
                new String[] {});
        doIncludeAncestorsRemove_Unrelated(
                new String[] {"/a/b/**/d"}, 
                new String[] {"/unrelated/child", "/unrelated", "/a/b/c/related/child", "/a/b/c/related", 
                        "/a/b/related/child", "/a/b/related", "/a/unrelated/child", "/a/unrelated", 
                        "/a"},
                new String[] {"/a", "/a/b/related", "/a/b/related/child", "/a/b/c/related/child", "/a/b/c/related"},
                new String[] {});
        doIncludeAncestorsRemove_Unrelated(
                new String[] {"/a/b/**/d/*.html"}, 
                new String[] {"/unrelated/child", "/unrelated", "/a/b/c/related/child", "/a/b/c/related", 
                        "/a/b/related/child", "/a/b/related", "/a/unrelated/child", "/a/unrelated", 
                        "/a"},
                new String[] {"/a", "/a/b/related", "/a/b/related/child", "/a/b/c/related/child", "/a/b/c/related"},
                new String[] {});

        doIncludeAncestorsRemove_Unrelated(
                new String[] {"/a/*/c/d"}, 
                new String[] {"/unrelated/child", "/unrelated", "/a/b/c/unrelated/child", "/a/b/c/unrelated", 
                        "/a/b/unrelated/child", "/a/b/unrelated", "/a/related/unrelatedchild", "/a/related", 
                        "/a/b"},
                new String[] {"/a/b", "/a/related"},
                new String[] {});
        doIncludeAncestorsRemove_Unrelated(
                new String[] {"/a/**/c/d"}, 
                new String[] {"/unrelated/child", "/unrelated", "/a/b/c/related/child", "/a/b/c/related", 
                        "/a/b/related/child", "/a/b/related", "/a/related/relatedchild", "/a/related", 
                        "/a/b"},
                new String[] {"/a/b", "/a/related/relatedchild", "/a/related", "/a/b/related/child", "/a/b/related", "/a/b/c/related/child", "/a/b/c/related"},
                new String[] {});
        doIncludeAncestorsRemove_Unrelated(
                new String[] {"/a/*/c/*.html"}, 
                new String[] {"/unrelated/child", "/unrelated", "/a/b/c/unrelated/child", "/a/b/c/unrelated", 
                        "/a/b/unrelated/child", "/a/b/unrelated", "/a/related/unrelatedchild", "/a/related", 
                        "/a/b/c/x.html", "/a/b"},
                new String[] {"/a/b", "/a/related", "/a/b/c/x.html"},
                new String[] {"/a/b/c/x.html/jcr:primaryType"});
        doIncludeAncestorsRemove_Unrelated(
                new String[] {"/a/**/c/*.html"}, 
                new String[] {"/unrelated/child", "/unrelated", "/a/b/c/related/child", "/a/b/c/related", 
                        "/a/b/related/child", "/a/b/related", "/a/related/child", "/a/related", 
                        "/a/b"},
                new String[] {"/a/b", "/a/related/child", "/a/related", "/a/b/related/child", "/a/b/related", "/a/b/c/related/child", "/a/b/c/related", "/a/b/c/related/child", "/a/b/c/related"},
                new String[] {});
    }
    
    private void doIncludeAncestorsRemove_Unrelated(String[] absPaths, 
            String[] createAndRemoveNodes, String[] expectedRemoveNodeEvents, String[] expectedRemovePropertyEvents) throws Exception {
        assumeTrue(observationManager instanceof ObservationManagerImpl);
        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;

        OakEventFilter filterWithAncestorsRemove = FilterFactory.wrap(new JackrabbitEventFilter());
        filterWithAncestorsRemove.setEventTypes(NODE_REMOVED | PROPERTY_REMOVED);
        assertTrue(absPaths.length >= 1);
        String[] additionalPaths = new String[absPaths.length];
        System.arraycopy(absPaths, 0, additionalPaths, 0, absPaths.length);
        if (!absPaths[0].contains("*")) {
            filterWithAncestorsRemove.setAbsPath(absPaths[0]);
            if (absPaths.length > 1) {
                additionalPaths = new String[absPaths.length - 1];
                System.arraycopy(absPaths, 1, additionalPaths, 0, absPaths.length - 1);
            }
        }
        filterWithAncestorsRemove.withIncludeGlobPaths(additionalPaths);
        filterWithAncestorsRemove.setIsDeep(true);
        filterWithAncestorsRemove = filterWithAncestorsRemove.withIncludeAncestorsRemove();
        ExpectationListener listenerWithAncestorsRemove = new ExpectationListener();
        oManager.addEventListener(listenerWithAncestorsRemove, filterWithAncestorsRemove);

        OakEventFilter filterWithoutAncestorsRemove = FilterFactory.wrap(new JackrabbitEventFilter());
        filterWithoutAncestorsRemove.setEventTypes(NODE_REMOVED);
        if (!absPaths[0].contains("*")) {
            filterWithoutAncestorsRemove.setAbsPath(absPaths[0]);
        }
        filterWithoutAncestorsRemove.withIncludeGlobPaths(additionalPaths);
        filterWithoutAncestorsRemove.setIsDeep(true);
        ExpectationListener listenerWithoutAncestorsRemove = new ExpectationListener();
        oManager.addEventListener(listenerWithoutAncestorsRemove, filterWithoutAncestorsRemove);

        Session session = getAdminSession();
        for (String path : createAndRemoveNodes) {
            Iterator<String> it = PathUtils.elements(path).iterator();
            Node node = session.getRootNode();
            while(it.hasNext()) {
                String elem = it.next();
                if (!node.hasNode(elem)) {
                    node = node.addNode(elem);
                } else {
                    node = node.getNode(elem);
                }
            }
        }
        session.save();

        for (String nodePath : expectedRemoveNodeEvents) {
            listenerWithAncestorsRemove.expect(nodePath, NODE_REMOVED);
        }
        for (String propertyPath : expectedRemovePropertyEvents) {
            listenerWithAncestorsRemove.expect(propertyPath, PROPERTY_REMOVED);
        }

        for (String path : createAndRemoveNodes) {
            Iterator<String> it = PathUtils.elements(path).iterator();
            Node node = session.getRootNode();
            while(it.hasNext()) {
                String elem = it.next();
                node = node.getNode(elem);
            }
            node.remove();
            session.save();
        }
        
        Thread.sleep(1000);
        List<Expectation> missing = listenerWithoutAncestorsRemove.getMissing(TIME_OUT, TimeUnit.SECONDS);
        List<Event> unexpected = listenerWithoutAncestorsRemove.getUnexpected();
        assertTrue("Unexpected events (listenerWithoutAncestorsRemove): " + unexpected, unexpected.isEmpty());
        assertTrue("Missing events (listenerWithoutAncestorsRemove): " + missing, missing.isEmpty());

        missing = listenerWithAncestorsRemove.getMissing(TIME_OUT, TimeUnit.SECONDS);
        unexpected = listenerWithAncestorsRemove.getUnexpected();
        assertTrue("Unexpected events (listenerWithAncestorsRemove): " + unexpected, unexpected.isEmpty());
        assertTrue("Missing events (listenerWithAncestorsRemove): " + missing, missing.isEmpty());
    }

    @Test
    public void includeAncestorsRemove() throws Exception {
        JackrabbitEventFilter filter = new JackrabbitEventFilter();
        filter.setEventTypes(ALL_EVENTS);
        filter.setAbsPath(TEST_PATH + "/a/b/c/d");
        filter.setIsDeep(true);
        filter = FilterFactory.wrap(filter).withIncludeAncestorsRemove();
        FilterProvider filterProvider = doIncludeAncestorsRemove(filter);
        // with 'includeAncestorsRemove' flag the listener is registered at '/'
        assertMatches(filterProvider.getSubTrees(), "/");

        filter = new JackrabbitEventFilter();
        filter.setEventTypes(ALL_EVENTS);
        filter.setIsDeep(true);
        filter = FilterFactory.wrap(filter).withIncludeAncestorsRemove().withIncludeGlobPaths(TEST_PATH + "/a/b/c/**");
        filterProvider = doIncludeAncestorsRemove(filter);
        // with 'includeAncestorsRemove' flag the listener is registered at '/'
        assertMatches(filterProvider.getSubTrees(), "/");

    }
    
    private FilterProvider doIncludeAncestorsRemove(JackrabbitEventFilter filter) throws Exception {
        assumeTrue(observationManager instanceof ObservationManagerImpl);

        Node testNode = getNode(TEST_PATH);
        testNode.addNode("a").addNode("b").addNode("c").addNode("d").setProperty("e", 42);
        testNode.getSession().save();

        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        
        oManager.addEventListener(listener, filter);

        Node d = testNode.getNode("a").getNode("b").getNode("c").getNode("d");
        Property e = d.getProperty("e");
        listener.expectRemove(e);
//        listener.expectRemove(d.getProperty("jcr:primaryType"));
//        d.remove();
        listener.expectRemove(d).remove();
        testNode.getSession().save();

        Thread.sleep(1000);
        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        assertTrue("Missing events: " + missing, missing.isEmpty());

        oManager.addEventListener(new EventListener() {
            
            @Override
            public void onEvent(EventIterator events) {
                while(events.hasNext()) {
                    System.out.println("/a-listener GOT: "+events.next());
                }
                
            }
        }, NODE_REMOVED, TEST_PATH + "/a", false, null, null, false);
        System.out.println("REGISTERED");
        
        testNode = getNode(TEST_PATH);
        Node b = testNode.getNode("a").getNode("b");
        listener.expect(b.getPath(), NODE_REMOVED);
        listener.optional(new Expectation("/a/b/c is optionally sent depending on filter") {
            @Override
            public boolean onEvent(Event event) throws Exception {
                if (event.getPath().equals(TEST_PATH + "/a/b/c") && event.getType() == NODE_REMOVED) {
                    return true;
                }
                return false;
            }
        });
        b.remove();
        // but not the jcr:primaryType
        testNode.getSession().save();

        Thread.sleep(1000);
        missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        unexpected = listener.getUnexpected();
        assertTrue("Missing events: " + missing, missing.isEmpty());
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    
        Node a = testNode.getNode("a");
        listener.expect(a.getPath(), NODE_REMOVED);
        a.remove();
        // but not the jcr:primaryType
        testNode.getSession().save();

        missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        assertTrue("Missing events: " + missing, missing.isEmpty());

        ChangeProcessor cp = oManager.getChangeProcessor(listener);
        assertNotNull(cp);
        FilterProvider filterProvider = cp.getFilterProvider();
        assertNotNull(filterProvider);
        return filterProvider;
    }

    @Test
    public void includeRemovedSubtree_Globs() throws Exception {
        assumeTrue(observationManager instanceof ObservationManagerImpl);

        Node testNode = getNode(TEST_PATH);
        testNode.addNode("a").addNode("b").addNode("c").addNode("d.jsp");
        testNode.addNode("e").addNode("f").addNode("g.jsp");
        testNode.getSession().save();

        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        
        JackrabbitEventFilter filter = new JackrabbitEventFilter();
        OakEventFilter oef = FilterFactory.wrap(filter);
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeGlobPaths(TEST_PATH + "/a/**/*.jsp");
        oef.withNodeTypeAggregate(new String[] {"nt:unstructured"}, new String[] {""});
        oef.withIncludeSubtreeOnRemove();

        oManager.addEventListener(listener, oef);

        // the glob is for a jsp - so we should (only) get an event for that
        // but only for the properties of d.jsp that get removed, not of removal of d.jsp itself
        // as that would again be reported towards the parent of d.jsp which is /a/b/c
        Node dDotJsp = testNode.getNode("a").getNode("b").getNode("c").getNode("d.jsp");
        listener.expect(dDotJsp.getPath() + "/jcr:primaryType", dDotJsp.getPath(), PROPERTY_REMOVED);

        // but we're removing /a/b
        testNode.getNode("a").getNode("b").remove();
        // and for removal of /e nothing should be generated
        testNode.getNode("e").remove();
        testNode.getSession().save();

        Thread.sleep(1000);
        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }
    
    @Test
    public void includeRemovedSubtree() throws RepositoryException, ExecutionException, InterruptedException {
        assumeTrue(observationManager instanceof ObservationManagerImpl);

        Node testNode = getNode(TEST_PATH);
        testNode.addNode("a").addNode("c");
        testNode.getSession().save();

        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        
        JackrabbitEventFilter filter = new JackrabbitEventFilter();
        filter.setEventTypes(ALL_EVENTS);
        filter.setAbsPath("/");
        filter.setIsDeep(true);
        filter = FilterFactory.wrap(filter).withIncludeSubtreeOnRemove();

        oManager.addEventListener(listener, filter);

        listener.expectRemove(testNode.getNode("a").getNode("c"));
        listener.expectRemove(testNode.getNode("a")).remove();
        testNode.getSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }
    
    @Test
    public void includeRemovedSubtree_BeforeValue() throws RepositoryException, ExecutionException, InterruptedException {
        assumeTrue(observationManager instanceof ObservationManagerImpl);

        Node testNode = getNode(TEST_PATH);
        Node a = testNode.addNode("a");
        a.setProperty("propA", "24");
        a.addNode("c").setProperty("propB", "42");
        testNode.getSession().save();

        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        
        JackrabbitEventFilter filter = new JackrabbitEventFilter();
        filter.setEventTypes(ALL_EVENTS);
        filter.setAbsPath("/");
        filter.setIsDeep(true);
        filter = FilterFactory.wrap(filter).withIncludeSubtreeOnRemove();

        oManager.addEventListener(listener, filter);

        Node c = testNode.getNode("a").getNode("c");
        listener.expectRemove(c);
        listener.expectBeforeValue(c.getProperty("propB").getPath(), PROPERTY_REMOVED, "42");
        a = testNode.getNode("a");
        listener.expectBeforeValue(a.getProperty("propA").getPath(), PROPERTY_REMOVED, "24");
        listener.expectRemove(a).remove();
        testNode.getSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }
    
    @Test
    public void includeGlobPaths() throws Exception {
        
        Node testNode = getNode(TEST_PATH);
        testNode.addNode("a1").addNode("b").addNode("c");
        testNode.addNode("a2").addNode("b").addNode("c");
        testNode.getSession().save();

        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        
        JackrabbitEventFilter filter = new JackrabbitEventFilter();
        filter.setEventTypes(ALL_EVENTS);
        filter = FilterFactory.wrap(filter).withIncludeGlobPaths(TEST_PATH + "/a2/**");
        oManager.addEventListener(listener, filter);
        ChangeProcessor cp = oManager.getChangeProcessor(listener);
        assertNotNull(cp);
        FilterProvider filterProvider = cp.getFilterProvider();
        assertNotNull(filterProvider);
        assertMatches(filterProvider.getSubTrees(), TEST_PATH + "/a2");

        testNode.getNode("a1").getNode("b").remove();
        listener.expectRemove(testNode.getNode("a2").getNode("b")).remove();
        testNode.getSession().save();
        
        Thread.sleep(1000);
        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        
        Node a3 = testNode.addNode("a3");
        Node foo = a3.addNode("bar").addNode("foo");
        testNode.getSession().save();

        filter = new JackrabbitEventFilter();
        filter.setEventTypes(ALL_EVENTS);
//        filter.setAbsPath(TEST_PATH + "/a3/bar/foo/x");
        filter = FilterFactory.wrap(filter).withIncludeGlobPaths(TEST_PATH + "/a3/**/x");
        oManager.addEventListener(listener, filter);
        cp = oManager.getChangeProcessor(listener);
        assertNotNull(cp);
        filterProvider = cp.getFilterProvider();
        assertNotNull(filterProvider);
        assertMatches(filterProvider.getSubTrees(), TEST_PATH + "/a3");
        
        Node x = foo.addNode("x");
        listener.expect(x.getPath() + "/jcr:primaryType", PROPERTY_ADDED);
        testNode.getSession().save();

        Thread.sleep(1000);
        missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());

        filter = new JackrabbitEventFilter();
        filter.setEventTypes(ALL_EVENTS);
        filter = FilterFactory.wrap(filter).withIncludeGlobPaths(TEST_PATH + "/a3/**/y");
        oManager.addEventListener(listener, filter);
        cp = oManager.getChangeProcessor(listener);
        assertNotNull(cp);
        filterProvider = cp.getFilterProvider();
        assertNotNull(filterProvider);
        assertMatches(filterProvider.getSubTrees(), TEST_PATH + "/a3");
        
        Node y = foo.addNode("y");
        listener.expect(y.getPath() + "/jcr:primaryType", PROPERTY_ADDED);
        testNode.getSession().save();

        Thread.sleep(1000);
        missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }
    
    @Test
    public void testConsecutiveGlobPaths() throws Exception {
        Node testNode = getNode(TEST_PATH);
        Node a1 = testNode.addNode("a1");
        a1.addNode("b1").addNode("c1");
        a1.addNode("b2").addNode("c2");
        testNode.addNode("a2").addNode("b").addNode("c");
        testNode.getSession().save();

        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        
        JackrabbitEventFilter filter = new JackrabbitEventFilter();
        filter.setEventTypes(ALL_EVENTS);
        filter = FilterFactory.wrap(filter).withIncludeGlobPaths(TEST_PATH + "/a2/**").withIncludeGlobPaths(TEST_PATH + "/a1/**");
        oManager.addEventListener(listener, filter);
        ChangeProcessor cp = oManager.getChangeProcessor(listener);
        assertNotNull(cp);
        FilterProvider filterProvider = cp.getFilterProvider();
        assertNotNull(filterProvider);
        assertMatches(filterProvider.getSubTrees(), TEST_PATH + "/a1", TEST_PATH + "/a2");

        listener.expectRemove(testNode.getNode("a1").getNode("b2")).remove();
        listener.expectRemove(testNode.getNode("a2").getNode("b")).remove();
        testNode.getSession().save();
        
        Thread.sleep(1000);
        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        assertTrue("Missing events: " + missing, missing.isEmpty());
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
    }

    @Test
    public void testAggregate1() throws Exception {
        assumeTrue(observationManager instanceof ObservationManagerImpl);
        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        JackrabbitEventFilter filter = new JackrabbitEventFilter();
        filter.setAbsPath("/parent");
        filter.setIsDeep(true);
        filter.setEventTypes(ALL_EVENTS);
        filter = FilterFactory.wrap(filter).withNodeTypeAggregate(new String[] { "oak:Unstructured" },
                new String[] { "", "jcr:content", "jcr:content/**" });
        oManager.addEventListener(listener, filter);
        Node parent = getAdminSession().getRootNode().addNode("parent", "nt:unstructured");
        // OAK-5096: in OR mode the following event also gets sent:
        listener.expect(parent.getPath() + "/jcr:primaryType", PROPERTY_ADDED);
        Node child = parent.addNode("child", "nt:unstructured");
        // OAK-5096: in OR mode the following event also gets sent:
        listener.expectAdd(child);
        Node file = child.addNode("file", "oak:Unstructured");
        listener.expectAdd(file);
        Node jcrContent = file.addNode("jcr:content", "nt:unstructured");
        listener.expect(jcrContent.getPath(), "/parent/child/file", NODE_ADDED);
        listener.expect(jcrContent.getPath() + "/jcr:primaryType", "/parent/child/file", PROPERTY_ADDED);
        Property jcrDataProperty = jcrContent.setProperty("jcr:data", "foo");
        listener.expect(jcrDataProperty.getPath(), "/parent/child/file", PROPERTY_ADDED);
        parent.getSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        assertTrue("Missing events: " + missing, missing.isEmpty());

        file = getAdminSession().getRootNode().getNode("parent").getNode("child").getNode("file");
        jcrContent = file.getNode("jcr:content");
        Property newProperty = jcrContent.setProperty("newProperty", "foo");
        listener.expect(newProperty.getPath(), "/parent/child/file", PROPERTY_ADDED);
        Property lastModifiedBy = jcrContent.setProperty("jcr:lastModifiedBy", "bar");
        listener.expect(lastModifiedBy.getPath(), "/parent/child/file", PROPERTY_ADDED);
        jcrContent.getSession().save();

        Thread.sleep(2000);
        missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        assertTrue("Missing events: " + missing, missing.isEmpty());
    }

    @Test
    public void testAggregate2() throws Exception {
        assumeTrue(observationManager instanceof ObservationManagerImpl);
        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        JackrabbitEventFilter filter = new JackrabbitEventFilter();
        filter.setAbsPath("/parent");
        filter.setIsDeep(true);
        filter.setEventTypes(ALL_EVENTS);
        filter = FilterFactory.wrap(filter).withNodeTypeAggregate(new String[] { "oak:Unstructured" },
                new String[] { "", "**" });// "file", "file/jcr:content",
                                           // "file/jcr:content/**");
        oManager.addEventListener(listener, filter);
        Node parent = getAdminSession().getRootNode().addNode("parent", "nt:unstructured");
        // OAK-5096: in OR mode the following event also gets sent:
        listener.expect(parent.getPath() + "/jcr:primaryType", PROPERTY_ADDED);
        Node child = parent.addNode("child", "oak:Unstructured");
        listener.expectAdd(child);
        Node file = child.addNode("file", "nt:unstructured");
        listener.expectAdd(file);
        Node jcrContent = file.addNode("jcr:content", "nt:unstructured");
        listener.expect(jcrContent.getPath(), "/parent/child", NODE_ADDED);
        listener.expect(jcrContent.getPath() + "/jcr:primaryType", "/parent/child", PROPERTY_ADDED);
        Property jcrDataProperty = jcrContent.setProperty("jcr:data", "foo");
        listener.expect(jcrDataProperty.getPath(), "/parent/child", PROPERTY_ADDED);
        parent.getSession().save();

        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        assertTrue("Missing events: " + missing, missing.isEmpty());

        file = getAdminSession().getRootNode().getNode("parent").getNode("child").getNode("file");
        jcrContent = file.getNode("jcr:content");
        Property newProperty = jcrContent.setProperty("newProperty", "foo");
        listener.expect(newProperty.getPath(), "/parent/child", PROPERTY_ADDED);
        Property lastModifiedBy = jcrContent.setProperty("jcr:lastModifiedBy", "bar");
        listener.expect(lastModifiedBy.getPath(), "/parent/child", PROPERTY_ADDED);
        jcrContent.getSession().save();

        Thread.sleep(2000);
        missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        assertTrue("Missing events: " + missing, missing.isEmpty());
    }

    @Test
    public void testAggregate3() throws Exception {
        assumeTrue(observationManager instanceof ObservationManagerImpl);
        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        JackrabbitEventFilter filter = new JackrabbitEventFilter();
        filter.setAbsPath("/parent");
        filter.setIsDeep(true);
        filter.setEventTypes(ALL_EVENTS);
        filter = FilterFactory.wrap(filter).withNodeTypeAggregate(new String[] { "oak:Unstructured" },
                new String[] { "**" } );
        oManager.addEventListener(listener, filter);
        
        Node parent = getAdminSession().getRootNode().addNode("parent", "nt:unstructured");
        // OAK-5096: in OR mode the following event also gets sent:
        listener.expect(parent.getPath() + "/jcr:primaryType", PROPERTY_ADDED);
        Node child = parent.addNode("child", "nt:unstructured");
        // OAK-5096: in OR mode the following event also gets sent:
        listener.expectAdd(child);
        Node file = child.addNode("file", "oak:Unstructured");
        listener.expect(file.getPath(), "/parent/child/file", NODE_ADDED);
        listener.expect(file.getPath() + "/jcr:primaryType", "/parent/child/file", PROPERTY_ADDED);
        Node jcrContent = file.addNode("jcr:content", "nt:unstructured");
        listener.expect(jcrContent.getPath(), "/parent/child/file", NODE_ADDED);
        listener.expect(jcrContent.getPath() + "/jcr:primaryType", "/parent/child/file", PROPERTY_ADDED);
        Property jcrDataProperty = jcrContent.setProperty("jcr:data", "foo");
        listener.expect(jcrDataProperty.getPath(), "/parent/child/file", PROPERTY_ADDED);
        parent.getSession().save();

        Thread.sleep(1000);
        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        assertTrue("Missing events: " + missing, missing.isEmpty());
    }
    
    @Test
    public void testAggregate4() throws Exception {
        assumeTrue(observationManager instanceof ObservationManagerImpl);
        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        JackrabbitEventFilter filter = new JackrabbitEventFilter();
        filter.setEventTypes(ALL_EVENTS);
        filter = FilterFactory.wrap(filter)
                .withNodeTypeAggregate(new String[] { "oak:Unstructured" }, new String[] { "**/foo/**" } )
                .withIncludeGlobPaths("/parent/**/bar/**");
        oManager.addEventListener(listener, filter);
        ChangeProcessor cp = oManager.getChangeProcessor(listener);
        assertNotNull(cp);
        FilterProvider filterProvider = cp.getFilterProvider();
        assertNotNull(filterProvider);
        assertMatches(filterProvider.getSubTrees(), "/parent");
        
        Node parent = getAdminSession().getRootNode().addNode("parent", "nt:unstructured");
        Node a = parent.addNode("a", "nt:unstructured");
        Node b = a.addNode("b", "nt:unstructured");
        Node bar = b.addNode("bar", "oak:Unstructured");
        // OAK-5096: in OR mode the following event also gets sent:
        listener.expect(bar.getPath() + "/jcr:primaryType", PROPERTY_ADDED);
        Node c = bar.addNode("c", "nt:unstructured");
        // OAK-5096: in OR mode the following event also gets sent:
        listener.expectAdd(c);
        Node foo = c.addNode("foo", "nt:unstructured");
        // OAK-5096: in OR mode the following event also gets sent:
        listener.expectAdd(foo);
        Node jcrContent = foo.addNode("jcr:content", "nt:unstructured");
        listener.expectAdd(jcrContent);
        
        parent.getSession().save();

        Thread.sleep(1000);
        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        assertTrue("Missing events: " + missing, missing.isEmpty());
    }

    /**
     * OAK-5096 : new test case for OR mode
     */
    @Test
    public void testAggregate5() throws Exception {
        assumeTrue(observationManager instanceof ObservationManagerImpl);
        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        JackrabbitEventFilter filter = new JackrabbitEventFilter();
        filter.setEventTypes(ALL_EVENTS);
        filter = FilterFactory.wrap(filter)
                .withNodeTypeAggregate(new String[] { "oak:Unstructured" }, new String[] { "**/foo/**" } )
                .withIncludeGlobPaths("/parent/**/bar/**");
        oManager.addEventListener(listener, filter);
        ChangeProcessor cp = oManager.getChangeProcessor(listener);
        assertNotNull(cp);
        FilterProvider filterProvider = cp.getFilterProvider();
        assertNotNull(filterProvider);
        assertMatches(filterProvider.getSubTrees(), "/parent");
        
        Node parent = getAdminSession().getRootNode().addNode("parent", "nt:unstructured");
        Node bar = parent.addNode("bar", "nt:unstructured");
        listener.expect(bar.getPath() + "/jcr:primaryType", PROPERTY_ADDED);
        Node c = bar.addNode("c", "nt:unstructured");
        listener.expectAdd(c);
        Node foo = c.addNode("foo", "nt:unstructured");
        listener.expectAdd(foo);
        Node jcrContent = foo.addNode("jcr:content", "nt:unstructured");
        listener.expectAdd(jcrContent);
        
        parent.getSession().save();

        Thread.sleep(1000);
        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        assertTrue("Missing events: " + missing, missing.isEmpty());
    }

    @Test
    public void testFileWithGlobs() throws Exception {
        doTestFileWithGlobs("/parent/bar/zet.jsp", "/parent/bar/zet.jsp");
        doTestFileWithGlobs("/parent/bar/*.jsp", "/parent/bar");
        doTestFileWithGlobs("/parent/*/zet.jsp", "/parent");
        doTestFileWithGlobs("/parent/*/*.jsp", "/parent");
        doTestFileWithGlobs("/parent/**/zet.jsp", "/parent");
        doTestFileWithGlobs("/parent/**/*.jsp", "/parent");
        doTestFileWithGlobs("/*/bar/*.jsp", "");
        doTestFileWithGlobs("/**/bar/*.jsp", "");
        doTestFileWithGlobs("/**/*.jsp", "");
        doTestFileWithGlobs("**/*.jsp", "");
    }

    private void doTestFileWithGlobs(String globPath, String... expectedSubTrees)
            throws RepositoryException, ItemExistsException, PathNotFoundException, NoSuchNodeTypeException,
            LockException, VersionException, ConstraintViolationException, AccessDeniedException,
            ReferentialIntegrityException, InvalidItemStateException, InterruptedException, ExecutionException {
        assumeTrue(observationManager instanceof ObservationManagerImpl);
        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        JackrabbitEventFilter filter = new JackrabbitEventFilter();
        filter.setEventTypes(ALL_EVENTS);
        filter = FilterFactory.wrap(filter)
                .withIncludeGlobPaths(globPath);
        oManager.addEventListener(listener, filter);
        ChangeProcessor cp = oManager.getChangeProcessor(listener);
        assertNotNull(cp);
        FilterProvider filterProvider = cp.getFilterProvider();
        assertNotNull(filterProvider);
        assertArrayEquals(expectedSubTrees, IterableUtils.toArray(filterProvider.getSubTrees(), String.class));
        
        Node parent = getAdminSession().getRootNode().addNode("parent", "nt:unstructured");
        Node bar = parent.addNode("bar", "nt:unstructured");
        Node zetDotJsp = bar.addNode("zet.jsp", "nt:unstructured");
        listener.expect(zetDotJsp.getPath() + "/jcr:primaryType", PROPERTY_ADDED);
        
        parent.getSession().save();

        Thread.sleep(1000);
        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        assertTrue("Missing events: " + missing, missing.isEmpty());
        
        Session session = getAdminSession();
        session.getRootNode().getNode("parent").remove();
        session.save();
        oManager.removeEventListener(listener);
    }

    // OAK-5096 : a specific **/*.jsp test case
    @Test
    public void testAggregate6() throws Exception {
        OakEventFilter oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.setIsDeep(true);
        oef.withIncludeAncestorsRemove()
                .withNodeTypeAggregate(new String[] { "oak:Unstructured" }, new String[] { "", "jcr:content" } )
                .withIncludeGlobPaths("/**/*.jsp");
        doTestAggregate6(oef, new String[] {"/"}, new String[] {"/**", "/**/*.jsp", "/**/*.jsp/**"});

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.setIsDeep(false);
        oef.withIncludeAncestorsRemove()
                .withNodeTypeAggregate(new String[] { "oak:Unstructured" }, new String[] { "", "jcr:content" } )
                .withIncludeGlobPaths("/**/*.jsp");
        doTestAggregate6(oef, new String[] {"/"}, new String[] {"/**", "/**/*.jsp", "/**/*.jsp/**"});
        
        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeAncestorsRemove()
                .withNodeTypeAggregate(new String[] { "oak:Unstructured" }, new String[] { "", "jcr:content" } )
                .withIncludeGlobPaths("**/*.jsp");
        doTestAggregate6(oef, new String[] {"/"}, new String[] {"/**", "**/*.jsp", "**/*.jsp/**"});

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef // without includeAncestorsRemove this time
                .withNodeTypeAggregate(new String[] { "oak:Unstructured" }, new String[] { "", "jcr:content" } )
                .withIncludeGlobPaths("/**/*.jsp");
        doTestAggregate6(oef, new String[] {""}, new String[] {"/**/*.jsp", "/**/*.jsp/**"});

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef // without includeAncestorsRemove this time
                .withNodeTypeAggregate(new String[] { "oak:Unstructured" }, new String[] { "", "jcr:content" } )
                .withIncludeGlobPaths("**/*.jsp");
        doTestAggregate6(oef, new String[] {""}, new String[] {"**/*.jsp", "**/*.jsp/**"});

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeAncestorsRemove()
                .withNodeTypeAggregate(new String[] { "oak:Unstructured" }, new String[] { "", "jcr:content" } )
                .withIncludeGlobPaths("/parent/**/*.jsp");
        doTestAggregate6(oef, new String[] {"/"}, new String[] {"/parent", "/parent/**", "/parent/**/*.jsp", "/parent/**/*.jsp/**"});
        
        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeAncestorsRemove()
                .withNodeTypeAggregate(new String[] { "oak:Unstructured" }, new String[] { "", "jcr:content" } )
                .withIncludeGlobPaths("/parent/bar/**/*.jsp");
        doTestAggregate6(oef, new String[] {"/"}, new String[] {"/parent", "/parent/bar", "/parent/bar/**", "/parent/bar/**/*.jsp", "/parent/bar/**/*.jsp/**"});
        
        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef // without includeAncestorsRemove this time
                .withNodeTypeAggregate(new String[] { "oak:Unstructured" }, new String[] { "", "jcr:content" } )
                .withIncludeGlobPaths("/parent/**/*.jsp");
        doTestAggregate6(oef, new String[] {"/parent"}, new String[] {"/parent/**/*.jsp", "/parent/**/*.jsp/**"});

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef // without includeAncestorsRemove this time
                .withNodeTypeAggregate(new String[] { "oak:Unstructured" }, new String[] { "", "jcr:content" } )
                .withIncludeGlobPaths("/parent/**/*.jsp", "/foo/bar/**");
        doTestAggregate6(oef, new String[] {"/parent", "/foo/bar"}, new String[] {"/foo/bar/**", "/parent/**/*.jsp", "/parent/**/*.jsp/**"});

        oef = FilterFactory.wrap(new JackrabbitEventFilter());
        oef.setEventTypes(ALL_EVENTS);
        oef.withIncludeAncestorsRemove()
                .withNodeTypeAggregate(new String[] { "oak:Unstructured" }, new String[] { "", "jcr:content" } )
                .withIncludeGlobPaths("/parent/**/*.jsp", "/foo/bar/**");
        doTestAggregate6(oef, new String[] {"/"}, new String[] {"/parent", "/foo", "/foo/bar", "/foo/bar/**", "/parent/**", "/parent/**/*.jsp", "/parent/**/*.jsp/**"});
    }

    private void doTestAggregate6(OakEventFilter oef, String[] expectedSubTrees, String[] expectedPrefilterPaths)
            throws Exception {
        assumeTrue(observationManager instanceof ObservationManagerImpl);
        ObservationManagerImpl oManager = (ObservationManagerImpl) observationManager;
        ExpectationListener listener = new ExpectationListener();
        oManager.addEventListener(listener, oef);
        ChangeProcessor cp = oManager.getChangeProcessor(listener);
        assertNotNull(cp);
        FilterProvider filterProvider = cp.getFilterProvider();
        assertNotNull(filterProvider);
        assertMatches(filterProvider.getSubTrees(), expectedSubTrees);
        ChangeSetFilterImpl changeSetFilter = (ChangeSetFilterImpl)PrivateAccessor.getField(filterProvider, "changeSetFilter");
        assertNotNull(changeSetFilter);
        assertMatches(changeSetFilter.getRootIncludePaths(), expectedPrefilterPaths);
        
        Node parent = getAdminSession().getRootNode().addNode("parent", "nt:unstructured");
        Node bar = parent.addNode("bar", "nt:unstructured");
        Node zetDotJsp = bar.addNode("zet.jsp", "nt:unstructured");
        listener.expect(zetDotJsp.getPath() + "/jcr:primaryType", PROPERTY_ADDED);
        Node c = bar.addNode("c", "nt:unstructured");
        Node fooDotJsp = c.addNode("foo.jsp", "oak:Unstructured");
        listener.expect(fooDotJsp.getPath() + "/jcr:primaryType", PROPERTY_ADDED);
        Node jcrContent = fooDotJsp.addNode("jcr:content", "nt:unstructured");
        jcrContent.setProperty("jcr:data", "foo");
        listener.expectAdd(jcrContent);
        listener.expect(jcrContent.getPath() + "/jcr:data", "/parent/bar/c/foo.jsp", PROPERTY_ADDED);
        
        parent.getSession().save();

        Thread.sleep(1000);
        List<Expectation> missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        List<Event> unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        assertTrue("Missing events: " + missing, missing.isEmpty());
        
        // OAK-5096 : this is what OAK-5096 is all about: when you change
        // the property jcr:content/jcr:data it should be reported with 
        // identifier of the aggregate - even though it is not in the original glob path
        jcrContent.setProperty("jcr:data", "bar");
        listener.expect(jcrContent.getPath() + "/jcr:data", "/parent/bar/c/foo.jsp", PROPERTY_CHANGED);
        parent.getSession().save();

        Thread.sleep(1000);
        missing = listener.getMissing(TIME_OUT, TimeUnit.SECONDS);
        unexpected = listener.getUnexpected();
        assertTrue("Unexpected events: " + unexpected, unexpected.isEmpty());
        assertTrue("Missing events: " + missing, missing.isEmpty());
        
        // cleanup
        Session session = getAdminSession();
        session.getRootNode().getNode("parent").remove();
        session.save();
        oManager.removeEventListener(listener);
    }

    private void assertMatches(Iterable<String> actuals, String... expected) {
        assertEquals(SetUtils.toSet(expected), SetUtils.toSet(actuals));
    }
}
