Merge pull request #18 from stefan-egli/SLING-9905

SLING-9905 : CleanupTest added to the project
diff --git a/pom.xml b/pom.xml
index 8a0e96d..9a5d42f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -327,6 +327,18 @@
             <version>3.1.0</version>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.apache.sling</groupId>
+            <artifactId>org.apache.sling.discovery.commons</artifactId>
+            <version>1.0.24</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.sling</groupId>
+            <artifactId>org.apache.sling.discovery.base</artifactId>
+            <version>2.0.10</version>
+            <scope>test</scope>
+        </dependency>
 
         <!-- JUnit -->
         <dependency>
diff --git a/src/main/java/org/apache/sling/event/impl/jobs/tasks/CleanUpTask.java b/src/main/java/org/apache/sling/event/impl/jobs/tasks/CleanUpTask.java
index 05bc079..66dc825 100644
--- a/src/main/java/org/apache/sling/event/impl/jobs/tasks/CleanUpTask.java
+++ b/src/main/java/org/apache/sling/event/impl/jobs/tasks/CleanUpTask.java
@@ -33,9 +33,11 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.time.Clock;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Calendar;
+import java.util.Date;
 import java.util.Iterator;
 import java.util.List;
 
@@ -67,6 +69,9 @@
     /** We count the scheduler runs. */
     private volatile long schedulerRuns;
 
+    /** specific clock instance that tests can the use to fiddle around with */
+    private Clock clock = Clock.systemDefaultZone();
+
     /**
      * Constructor
      */
@@ -75,6 +80,22 @@
         this.jobScheduler = jobScheduler;
     }
 
+    /** test hook to overwrite the default clock and fiddle with it */
+    void setClock(Clock clock) {
+        this.clock = clock;
+    }
+
+    private final Calendar getCalendarInstance() {
+        Calendar calendar = Calendar.getInstance();
+        // explicitly set the time based on the clock to allow test fiddlings
+        calendar.setTimeInMillis(clock.millis());
+        return calendar;
+    }
+
+    private final long currentTimeMillis() {
+        return clock.millis();
+    }
+
     /**
      * One maintenance run
      */
@@ -115,7 +136,7 @@
 
         if (this.configuration.getHistoryCleanUpRemovedJobs() > 0 &&
                 schedulerRuns % 60 == 1) {
-            Calendar removeDate = Calendar.getInstance();
+            Calendar removeDate = getCalendarInstance();
             removeDate.add(Calendar.MINUTE, - this.configuration.getHistoryCleanUpRemovedJobs());
             this.historyCleanUpRemovedJobs(removeDate);
         }
@@ -187,7 +208,7 @@
         this.logger.debug("Cleaning up job resource tree: looking for empty folders");
         final ResourceResolver resolver = this.configuration.createResourceResolver();
         try {
-            final Calendar cleanUpDate = Calendar.getInstance();
+            final Calendar cleanUpDate = getCalendarInstance();
             // go back five minutes
             cleanUpDate.add(Calendar.MINUTE, -5);
 
@@ -255,7 +276,7 @@
             final Resource baseResource = resolver.getResource(basePath);
             // sanity check - should never be null
             if ( baseResource != null ) {
-                final Calendar now = Calendar.getInstance();
+                final Calendar now = getCalendarInstance();
                 final int removeYear = now.get(Calendar.YEAR);
                 final int removeMonth = now.get(Calendar.MONTH) + 1;
                 final int removeDay = now.get(Calendar.DAY_OF_MONTH);
@@ -385,7 +406,8 @@
                         if ( !hasJobs(caps, r) ) {
                             // check for timestamp
                             final long timestamp = r.getValueMap().get(PROPERTY_LAST_CHECKED, -1L);
-                            if ( timestamp > 0 && (timestamp + KEEP_DURATION <= System.currentTimeMillis()) ) {
+                            final long now = currentTimeMillis();
+                            if ( timestamp > 0 && (timestamp + KEEP_DURATION <= now) ) {
                                 toDelete.add(r);
                                 if ( toDelete.size() == MAX_REMOVE_ID_FOLDERS ) {
                                     break;
@@ -393,7 +415,7 @@
                             } else if ( timestamp == -1 ) {
                                 final ModifiableValueMap mvm = r.adaptTo(ModifiableValueMap.class);
                                 if ( mvm != null ) {
-                                    mvm.put(PROPERTY_LAST_CHECKED, System.currentTimeMillis());
+                                    mvm.put(PROPERTY_LAST_CHECKED, now);
                                     resolver.commit();
                                 }
                             }
diff --git a/src/test/java/org/apache/sling/event/impl/jobs/tasks/CleanUpTest.java b/src/test/java/org/apache/sling/event/impl/jobs/tasks/CleanUpTest.java
new file mode 100644
index 0000000..4be8487
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/jobs/tasks/CleanUpTest.java
@@ -0,0 +1,419 @@
+/*
+ * 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.sling.event.impl.jobs.tasks;
+
+import static org.junit.Assert.assertEquals;
+
+import java.text.SimpleDateFormat;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.Calendar;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.UUID;
+
+import org.apache.sling.api.resource.PersistenceException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.discovery.ClusterView;
+import org.apache.sling.discovery.InstanceDescription;
+import org.apache.sling.discovery.TopologyView;
+import org.apache.sling.discovery.base.commons.DefaultTopologyView;
+import org.apache.sling.discovery.commons.providers.DefaultClusterView;
+import org.apache.sling.discovery.commons.providers.DefaultInstanceDescription;
+import org.apache.sling.event.impl.jobs.config.JobManagerConfiguration;
+import org.apache.sling.event.impl.jobs.config.TopologyCapabilities;
+import org.apache.sling.event.impl.jobs.scheduling.JobSchedulerImpl;
+import org.apache.sling.event.impl.support.ResourceHelper;
+import org.apache.sling.testing.mock.sling.junit.SlingContext;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.mockito.junit.MockitoRule;
+import org.mockito.quality.Strictness;
+
+@RunWith(MockitoJUnitRunner.class)
+public class CleanUpTest {
+
+    static class DynamicClock extends Clock {
+
+        private final Clock baseClock;
+        private Duration offset;
+
+        DynamicClock(Clock baseClock, Duration offsetOrNull) {
+            this.baseClock = baseClock;
+            setOffset(offsetOrNull);
+        }
+
+        public void setOffset(Duration offsetOrNull) {
+            this.offset = offsetOrNull;
+        }
+
+        public void incrementOffset(Duration offsetOrNull) {
+            if (offsetOrNull == null) {
+                // then why call this?
+                return;
+            }
+            if (this.offset == null) {
+                this.offset = offsetOrNull;
+            } else {
+                final long newOffsetMillis = Math.addExact(offsetOrNull.toMillis(), offset.toMillis());
+                this.offset = Duration.ofMillis(newOffsetMillis);
+            }
+        }
+
+        @Override
+        public ZoneId getZone() {
+            return baseClock.getZone();
+        }
+
+        @Override
+        public Clock withZone(ZoneId zone) {
+            return new DynamicClock(baseClock.withZone(zone), offset);
+        }
+
+        @Override
+        public Instant instant() {
+            if (offset == null) {
+                return baseClock.instant();
+            }
+            return baseClock.instant().plus(offset);
+        }
+
+        @Override
+        public long millis() {
+            if (offset == null) {
+                return baseClock.millis();
+            }
+            return Math.addExact(baseClock.millis(), offset.toMillis());
+        }
+    }
+
+    private static final String JCR_PATH = JobManagerConfiguration.DEFAULT_REPOSITORY_PATH + "/cancelled";
+    private static final String ASSIGNED_JOBS_JCR_PATH = JobManagerConfiguration.DEFAULT_REPOSITORY_PATH + "/assigned";
+    private static final String UNASSIGNED_JOBS_JCR_PATH = JobManagerConfiguration.DEFAULT_REPOSITORY_PATH + "/unassigned";
+    private static final String JCR_TOPIC = "test";
+    private static final String JCR_JOB_NAME = "test-job";
+    private static final SimpleDateFormat DATE_FORMATTER = new SimpleDateFormat("yyyy/MM/dd/HH/mm");
+//    private static final int MAX_AGE_IN_DAYS = 60;
+
+    @Rule
+    public final SlingContext ctx = new SlingContext();
+
+    @Mock
+    private JobManagerConfiguration configuration;
+    @Mock
+    private JobSchedulerImpl jobScheduler;
+
+    @Rule
+    public MockitoRule rule = MockitoJUnit.rule().strictness(Strictness.LENIENT);
+
+    private final String localSlingId = "myLocalSlingId";
+    private CleanUpTask task;
+    private TopologyCapabilities capabilities;
+    private TopologyView view;
+    private DynamicClock clock;
+
+    @Before
+    public void setUp() {
+        // let's use a fixed calendar which we can fast-forward as needed
+        Calendar realCalendar = Calendar.getInstance();
+        clock = new DynamicClock(Clock.fixed(realCalendar.toInstant(), ZoneId.systemDefault()), null);
+
+        setupConfiguration();
+        setUpTask();
+        createResource(JobManagerConfiguration.DEFAULT_REPOSITORY_PATH);
+
+        task.setClock(clock);
+    }
+    public static DefaultInstanceDescription createInstanceDescription(
+            String instanceId, boolean isLocal, ClusterView clusterView) {
+        if (!(clusterView instanceof DefaultClusterView)) {
+            throw new IllegalArgumentException(
+                    "Must pass a clusterView of type "
+                            + DefaultClusterView.class);
+        }
+        DefaultInstanceDescription i = new DefaultInstanceDescription(
+                (DefaultClusterView) clusterView, false, isLocal, instanceId, new HashMap<String, String>());
+        return i;
+    }
+
+    public static DefaultTopologyView createTopologyView(String clusterViewId,
+            String slingId) {
+        DefaultTopologyView t = new DefaultTopologyView();
+        DefaultClusterView c = new DefaultClusterView(clusterViewId);
+        DefaultInstanceDescription i = new DefaultInstanceDescription(
+                c, true, true, slingId, new HashMap<String, String>());
+        Collection<InstanceDescription> instances = new LinkedList<InstanceDescription>();
+        instances.add(i);
+        t.addInstances(instances);
+        return t;
+    }
+
+    private TopologyView createView(boolean current) {
+        return createTopologyView(UUID.randomUUID().toString(), UUID.randomUUID().toString());
+    }
+
+    private void setupConfiguration() {
+        Mockito.when(configuration.getLocalJobsPath()).thenReturn(ASSIGNED_JOBS_JCR_PATH + "/" + localSlingId);
+        Mockito.when(configuration.getUnassignedJobsPath()).thenReturn(UNASSIGNED_JOBS_JCR_PATH);
+        Mockito.when(configuration.getStoredCancelledJobsPath()).thenReturn(JCR_PATH);
+        Mockito.when(configuration.createResourceResolver()).thenReturn(ctx.resourceResolver());
+        Mockito.when(configuration.getHistoryCleanUpRemovedJobs()).thenReturn(1);
+
+        view = createView(true);
+        Mockito.when(configuration.getAssginedJobsPath()).thenReturn(ASSIGNED_JOBS_JCR_PATH);
+
+        capabilities = new TopologyCapabilities(view, configuration);
+        Mockito.when(configuration.getTopologyCapabilities()).thenReturn(capabilities);
+        Mockito.when(capabilities.isActive()).thenReturn(true);
+    }
+
+    private void setUpTask() {
+        task = new CleanUpTask(configuration, jobScheduler);
+    }
+
+//    private Resource createJobResourceForDate(Calendar cal, String status) {
+//        String path = JCR_PATH + '/' + JCR_TOPIC + '/' + DATE_FORMATTER.format(cal.getTime()) + '/' + JCR_JOB_NAME;
+//        return ctx.create().resource(path, JobImpl.PROPERTY_FINISHED_STATE, status);
+//    }
+
+    private Resource createJobResourceForDate(String rootPath, Calendar cal) {
+        String path = rootPath + '/' + JCR_TOPIC + '/' + DATE_FORMATTER.format(cal.getTime()) + '/' + JCR_JOB_NAME;
+        return ctx.create().resource(path, "sling:resourceType", ResourceHelper.RESOURCE_TYPE_JOB);
+    }
+
+//    private void deleteJobResourceForDate(String rootPath, Calendar cal) throws PersistenceException {
+//        String path = rootPath + '/' + JCR_TOPIC + '/' + DATE_FORMATTER.format(cal.getTime()) + '/' + JCR_JOB_NAME;
+//        ResourceResolver resolver = ctx.resourceResolver();
+//        Resource r = resolver.getResource(path);
+//        resolver.delete(r);
+//        resolver.commit();
+//    }
+
+    private void deleteResource(Resource r) throws PersistenceException {
+        ResourceResolver resolver = ctx.resourceResolver();
+        resolver.delete(r);
+        resolver.commit();
+    }
+
+    private Resource createEmptyJobResourceForDate(String rootPath, Calendar cal) {
+        String path = rootPath + '/' + JCR_TOPIC + '/' + DATE_FORMATTER.format(cal.getTime());
+        return ctx.create().resource(path);
+    }
+
+    private Resource createResource(String rootPath) {
+        return ctx.create().resource(rootPath);
+    }
+
+    // new calendar based on the (test) clock
+    private Calendar getCalendarInstance() {
+        Calendar calendar = Calendar.getInstance();
+        calendar.setTimeInMillis(clock.millis());
+        return calendar;
+    }
+
+    private void simulate(int num, Duration duration) {
+        for(int i=0; i<num; i++) {
+            clock.incrementOffset(duration);
+            task.run();
+        }
+    }
+    private int countFolders(String path) {
+        final ResourceResolver resolver = this.configuration.createResourceResolver();
+        if ( resolver == null ) {
+            return -1;
+        }
+        try {
+            final Resource baseResource = resolver.getResource(path);
+            return countChildren(baseResource);
+        } finally {
+            resolver.close();
+        }
+    }
+    private int countChildren(Resource parent) {
+        if (parent == null) {
+            return 0;
+        }
+        int count = 0;
+        final Iterator<Resource> it = parent.listChildren();
+        if (it != null) {
+            while( it.hasNext() ) {
+                final Resource child = it.next();
+                final int childCount = countChildren(child);
+                count += childCount;
+            }
+        }
+        return 1 + count;
+    }
+
+    @Test
+    public void testEmpty() {
+        assertEquals(1, countFolders(JobManagerConfiguration.DEFAULT_REPOSITORY_PATH));
+        createResource(ASSIGNED_JOBS_JCR_PATH);
+        createResource(UNASSIGNED_JOBS_JCR_PATH);
+        assertEquals(3, countFolders(JobManagerConfiguration.DEFAULT_REPOSITORY_PATH));
+        // 2 days worth of cleanup
+        for( int i = 0; i < 48; i++) {
+            simulate(60, Duration.ofMinutes(1));
+        }
+        assertEquals(3, countFolders(JobManagerConfiguration.DEFAULT_REPOSITORY_PATH));
+    }
+
+    @Test
+    public void testAssignedEmpty() {
+        assertEquals(1, countFolders(JobManagerConfiguration.DEFAULT_REPOSITORY_PATH));
+        createResource(ASSIGNED_JOBS_JCR_PATH);
+        assertEquals(2, countFolders(JobManagerConfiguration.DEFAULT_REPOSITORY_PATH));
+        for( int i = 0; i < 100; i++) {
+            createResource(ASSIGNED_JOBS_JCR_PATH + "/" + UUID.randomUUID().toString());
+        }
+        assertEquals(102, countFolders(JobManagerConfiguration.DEFAULT_REPOSITORY_PATH));
+        // 2 days worth of cleanup
+        for( int i = 0; i < 480; i++) {
+            simulate(60, Duration.ofMinutes(1));
+        }
+        assertEquals(2, countFolders(JobManagerConfiguration.DEFAULT_REPOSITORY_PATH));
+    }
+
+    @Test
+    public void testAssignedSimple_nonLocal() throws PersistenceException {
+        final String mySlingId = UUID.randomUUID().toString();
+        Calendar calendar = getCalendarInstance();
+        Resource job = createJobResourceForDate(ASSIGNED_JOBS_JCR_PATH + "/" + mySlingId, calendar);
+        calendar.add(Calendar.DAY_OF_YEAR,-1);
+        createEmptyJobResourceForDate(ASSIGNED_JOBS_JCR_PATH + "/" + mySlingId, calendar);
+        assertEquals(11, countFolders(ASSIGNED_JOBS_JCR_PATH + "/" + mySlingId));
+
+        // full clean up is done every hour
+        // so let's simulate an hour
+        simulate(60, Duration.ofMinutes(1));
+
+        // after the first run it will just have marked the folder for deletion, but not deleted, so counter is still the same
+        assertEquals(11, countFolders(ASSIGNED_JOBS_JCR_PATH + "/" + mySlingId));
+
+        // simulate 72 hours
+        for( int i = 0; i < 72; i++) {
+            simulate(60, Duration.ofMinutes(1));
+        }
+        // nothing should be deleted
+        assertEquals(11, countFolders(ASSIGNED_JOBS_JCR_PATH + "/" + mySlingId));
+
+        deleteResource(job);
+        for( int i = 0; i < 72; i++) {
+            simulate(60, Duration.ofMinutes(1));
+        }
+        assertEquals(0, countFolders(ASSIGNED_JOBS_JCR_PATH + "/" + mySlingId));
+    }
+
+    @Test
+    public void testAssignedSimple_local() throws PersistenceException {
+        final String mySlingId = localSlingId;
+        Calendar calendar = getCalendarInstance();
+        Resource jobResource = createJobResourceForDate(ASSIGNED_JOBS_JCR_PATH + "/" + mySlingId, calendar);
+        calendar.add(Calendar.DAY_OF_YEAR,-1);
+        createEmptyJobResourceForDate(ASSIGNED_JOBS_JCR_PATH + "/" + mySlingId, calendar);
+        assertEquals(11, countFolders(ASSIGNED_JOBS_JCR_PATH + "/" + mySlingId));
+
+        // full clean up is done every hour
+        // so let's simulate an hour
+        simulate(60, Duration.ofMinutes(1));
+
+        // after the first run it will just have marked the folder for deletion, but not deleted, so counter is still the same
+        assertEquals(8, countFolders(ASSIGNED_JOBS_JCR_PATH + "/" + mySlingId));
+
+        // simulate 24 hours - still nothing
+        for( int i = 0; i < 24; i++) {
+            simulate(60, Duration.ofMinutes(1));
+            assertEquals("i = " + i, 8, countFolders(ASSIGNED_JOBS_JCR_PATH + "/" + mySlingId));
+        }
+
+        // lets delete the job
+        deleteResource(jobResource);
+
+        for( int i = 0; i < 25; i++) {
+            simulate(60, Duration.ofMinutes(1));
+        }
+        assertEquals(0, countFolders(ASSIGNED_JOBS_JCR_PATH + "/" + mySlingId));
+    }
+
+    @Test
+    public void testUnassigned_nothingToDelete() {
+        Calendar calendar = getCalendarInstance();
+        createJobResourceForDate(UNASSIGNED_JOBS_JCR_PATH, calendar);
+        assertEquals(8, countFolders(UNASSIGNED_JOBS_JCR_PATH));
+
+        // full clean up is done every hour
+        simulate(60, Duration.ofMinutes(1));
+
+        // fullEmptyFolderCleanup deletes only if 2 hour values older - so nothing changed yet
+        assertEquals(8, countFolders(UNASSIGNED_JOBS_JCR_PATH));
+
+        simulate(60, Duration.ofMinutes(1));
+        assertEquals(8, countFolders(UNASSIGNED_JOBS_JCR_PATH));
+
+        // simulate 23 hours
+        for( int i = 0; i < 23; i++) {
+            simulate(60, Duration.ofMinutes(1));
+            assertEquals("i = " + i, 8, countFolders(UNASSIGNED_JOBS_JCR_PATH));
+        }
+
+        // now on the next run it should finally do its job
+        simulate(60, Duration.ofMinutes(1));
+        assertEquals(8, countFolders(UNASSIGNED_JOBS_JCR_PATH));
+    }
+
+    @Test
+    public void testUnassignedSimple() throws PersistenceException {
+        Calendar calendar = getCalendarInstance();
+        Resource job = createJobResourceForDate(UNASSIGNED_JOBS_JCR_PATH, calendar);
+        calendar.add(Calendar.DAY_OF_YEAR,-1);
+        createEmptyJobResourceForDate(UNASSIGNED_JOBS_JCR_PATH, calendar);
+        assertEquals(11, countFolders(UNASSIGNED_JOBS_JCR_PATH));
+
+        // full clean up is done every hour
+        // so let's simulate an hour
+        simulate(60, Duration.ofMinutes(1));
+
+        // after the first run it will just have marked the folder for deletion, but not deleted, so counter is still the same
+        assertEquals(8, countFolders(UNASSIGNED_JOBS_JCR_PATH));
+
+        // simulate 72 hours
+        for( int i = 0; i < 72; i++) {
+            simulate(60, Duration.ofMinutes(1));
+        }
+        assertEquals(8, countFolders(UNASSIGNED_JOBS_JCR_PATH));
+
+        deleteResource(job);
+
+        for( int i = 0; i < 72; i++) {
+            simulate(60, Duration.ofMinutes(1));
+        }
+        assertEquals(4, countFolders(UNASSIGNED_JOBS_JCR_PATH));
+    }
+}