Add WindowsFileDelegateTest

This adds a basic WindowsFileDelegateTest.  It tests newDocId() and
most file system monitor functions using the local file system.
It only runs on Windows.

This change adds a startSignal CountDownLatch that the monitor uses
to signal that the monitor is actively listening for changes, and
startMonitorPath() waits for the monitor thread to be up and running
before returning.

TODO: Add tests for ACL features.
TODO: Add tests for DFS features.

Code Review: https://codereview.appspot.com/57170048/
diff --git a/src/com/google/enterprise/adaptor/fs/WindowsFileDelegate.java b/src/com/google/enterprise/adaptor/fs/WindowsFileDelegate.java
index 4b864ad..18b139b 100644
--- a/src/com/google/enterprise/adaptor/fs/WindowsFileDelegate.java
+++ b/src/com/google/enterprise/adaptor/fs/WindowsFileDelegate.java
@@ -51,6 +51,7 @@
 import java.util.Arrays;
 import java.util.List;
 import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CountDownLatch;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
@@ -230,10 +231,17 @@
           + ". The path is not a valid directory.");
     }
 
+    CountDownLatch startSignal = new CountDownLatch(1);
     synchronized (monitorThreadLock) {
-      monitorThread = new MonitorThread(watchPath, queue);
+      monitorThread = new MonitorThread(watchPath, queue, startSignal);
       monitorThread.start();
     }
+    // Wait for the monitor thread to start watching filesystem.
+    try {
+      startSignal.await();
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+    }
   }
 
   @Override
@@ -249,13 +257,18 @@
   private static class MonitorThread extends Thread {
     private final Path watchPath;
     private final BlockingQueue<Path> queue;
+    private final CountDownLatch startSignal;
     private final HANDLE stopEvent;
 
-    public MonitorThread(Path watchPath, BlockingQueue<Path> queue) {
+    public MonitorThread(Path watchPath, BlockingQueue<Path> queue,
+        CountDownLatch startSignal) {
       Preconditions.checkNotNull(watchPath, "the watchPath may not be null");
       Preconditions.checkNotNull(queue, "the queue may not be null");
+      Preconditions.checkNotNull(startSignal,
+                                 "the start signal may not be null");
       this.watchPath = watchPath;
       this.queue = queue;
+      this.startSignal = startSignal;
       stopEvent = Kernel32.INSTANCE.CreateEvent(null, false, false, null);
     }
 
@@ -283,6 +296,9 @@
         runMonitorLoop();
       } catch (IOException e) {
         log.log(Level.WARNING, "Unable to monitor " + watchPath, e);
+      } finally {
+        // Wake up caller, in case monitor fails to start up.
+        startSignal.countDown();
       }
       log.exiting("WindowsFileDelegate", "MonitorThread.run", watchPath);
     }
@@ -360,6 +376,9 @@
               + ". GetLastError: " + klib.GetLastError());
         }
 
+        // Signal any waiting threads that the monitor is now active.
+        startSignal.countDown();
+
         log.log(Level.FINER, "Waiting for notifications.");
         int waitResult = klib.WaitForSingleObjectEx(stopEvent,
             Kernel32.INFINITE, true);
diff --git a/test/com/google/enterprise/adaptor/fs/WindowsFileDelegateTest.java b/test/com/google/enterprise/adaptor/fs/WindowsFileDelegateTest.java
new file mode 100644
index 0000000..f345598
--- /dev/null
+++ b/test/com/google/enterprise/adaptor/fs/WindowsFileDelegateTest.java
@@ -0,0 +1,291 @@
+// Copyright 2014 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.enterprise.adaptor.fs;
+
+import com.google.common.base.Charsets;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Sets;
+import com.google.common.io.CharStreams;
+
+import static org.junit.Assert.*;
+import static org.junit.Assume.*;
+
+import org.junit.*;
+import org.junit.rules.ExpectedException;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.AclFileAttributeView;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.util.Collections;
+import java.util.Set;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+/** Tests for {@link WindowsFileDelegate} */
+public class WindowsFileDelegateTest {
+
+  @BeforeClass
+  public static void checkIfRunningOnWindows() {
+    TestHelper.assumeOsIsWindows();
+  }
+
+  private FileDelegate delegate = new WindowsFileDelegate();
+  private BlockingQueue<Path> queue = new LinkedBlockingQueue<Path>();
+  private Path tempRoot;
+
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  @Rule
+  public TemporaryFolder temp = new TemporaryFolder();
+
+  @Before
+  public void setUp() throws Exception {
+    tempRoot = temp.getRoot().getCanonicalFile().toPath();
+  }
+    
+  @After
+  public void tearDown() {
+    delegate.destroy();
+  }
+
+  private Path newTempDir(String name) throws IOException {
+    return temp.newFolder(name).toPath();
+  }
+
+  private Path newTempFile(String name) throws IOException {
+    return temp.newFile(name).toPath();
+  }
+
+  private Path newTempFile(Path parent, String name) throws IOException {
+    Preconditions.checkArgument(parent.startsWith(tempRoot));
+    return Files.createFile(parent.resolve(name));
+  }
+
+  @Test
+  public void testNewDocIdLocalFiles() throws Exception {
+    Path dir = newTempDir("testDir");
+    Path file = newTempFile(dir, "test");
+
+    String id = delegate.newDocId(tempRoot).getUniqueId();
+    assertTrue(id.startsWith(tempRoot.toString().replace('\\', '/')));
+    assertTrue(id.endsWith("/"));
+
+    id = delegate.newDocId(dir).getUniqueId();
+    assertTrue(id.startsWith(tempRoot.toString().replace('\\', '/')));
+    assertTrue(id.startsWith(dir.toString().replace('\\', '/')));
+    assertTrue(id.endsWith("/"));
+
+    id = delegate.newDocId(file).getUniqueId();
+    assertTrue(id.startsWith(tempRoot.toString().replace('\\', '/')));
+    assertTrue(id.startsWith(dir.toString().replace('\\', '/')));
+    assertTrue(id.equals(file.toString().replace('\\', '/')));
+    assertFalse(id.endsWith("/"));
+  }
+
+  @Test
+  public void testNewDocIdVirtualUncPaths() throws Exception {
+    assertEquals("\\\\host/share",
+        delegate.newDocId(Paths.get("\\\\host\\share")).getUniqueId());
+    assertEquals("\\\\host/share",
+        delegate.newDocId(Paths.get("\\\\host\\share\\")).getUniqueId());
+    assertEquals("\\\\host/share/foo/bar",
+        delegate.newDocId(Paths.get("\\\\host\\share\\foo\\bar"))
+        .getUniqueId());
+  }
+
+  @Test
+  public void testNewDocIdLocalUncPaths() throws Exception {
+    String uncTempRoot = getTempRootAsUncPath();
+    assumeNotNull(uncTempRoot);
+    Path tempRoot = Paths.get(uncTempRoot);
+    String expectedTempRootId = "\\\\" + uncTempRoot.substring(2)
+        .replace('\\', '/') + "/";
+
+    assertEquals(expectedTempRootId,
+        delegate.newDocId(tempRoot).getUniqueId());
+
+    newTempDir("testDir");
+    assertEquals(expectedTempRootId + "testDir/",
+        delegate.newDocId(tempRoot.resolve("testDir")).getUniqueId());
+
+    newTempFile("test");
+    assertEquals(expectedTempRootId + "test",
+        delegate.newDocId(tempRoot.resolve("test")).getUniqueId());
+  }
+
+  private String getTempRootAsUncPath() throws IOException {
+    String tempPath = temp.getRoot().getCanonicalPath();
+    if (tempPath.length() > 2 && tempPath.charAt(1) == ':') {
+      String uncPath = "\\\\localhost\\" + tempPath.substring(0, 1) + "$"
+          + tempPath.substring(2);
+      try {
+        // Now verify we have access to the local administrative share.
+        if (new File(uncPath).list() != null) {
+          return uncPath;
+        }
+      } catch (SecurityException e) {
+        // Cannot access local administrative share.
+      }
+    }
+    return null;
+  }
+
+  @Test
+  public void testStartMonitorBadPath() throws Exception {
+    Path file = newTempFile("test.txt");
+    thrown.expect(IOException.class);
+    delegate.startMonitorPath(file, queue);
+  }
+
+  @Test
+  public void testStartStopMonitor() throws Exception {
+    delegate.startMonitorPath(tempRoot, queue);
+    delegate.stopMonitorPath();
+  }
+
+  @Test
+  public void testMonitorAddFile() throws Exception {
+    // These shouldn't show up as new or modified.
+    newTempDir("existingDir");
+    newTempFile("existingFile");
+    delegate.startMonitorPath(tempRoot, queue);
+    Path file = newTempFile("test.txt");
+    // Adding a file shows up as a change to its parent.
+    checkForChanges(Collections.singleton(tempRoot));
+  }
+
+  @Test
+  public void testMonitorDeleteFile() throws Exception {
+    Path file = newTempFile("test.txt");
+    delegate.startMonitorPath(tempRoot, queue);
+    Files.delete(file);
+    // Deleting a file shows up as a change to itself and its parent.
+    checkForChanges(Sets.newHashSet(tempRoot, file));
+  }
+
+  @Test
+  public void testMonitorRenameFile() throws Exception {
+    Path file = newTempFile("test.txt");
+    Path newFile = file.resolveSibling("newName.txt");
+    delegate.startMonitorPath(tempRoot, queue);
+    Files.move(file, newFile, StandardCopyOption.ATOMIC_MOVE);
+    // Renaming a file shows up as a change to its old name, its new name,
+    // and its parent.
+    checkForChanges(Sets.newHashSet(tempRoot, file, newFile));
+  }
+
+  @Test
+  public void testMonitorMoveAccrossDirs() throws Exception {
+    Path dir1 = newTempDir("dir1");
+    Path dir2 = newTempDir("dir2");
+    Path file1 = newTempFile(dir1, "test.txt");
+    Path file2 = dir2.resolve(file1.getFileName());
+    delegate.startMonitorPath(tempRoot, queue);
+    Files.move(file1, file2);
+    // Moving a file shows up as a change to its old name, its new name,
+    // its old parent, and its new parent.
+    checkForChanges(Sets.newHashSet(file1, file2, dir1, dir2));
+  }
+
+  @Test
+  public void testMonitorModifyFile() throws Exception {
+    Path file = newTempFile("test.txt");
+    delegate.startMonitorPath(tempRoot, queue);
+    Files.write(file, "Hello World".getBytes("UTF-8"));
+    // Modifying a file shows up as a change to that file.
+    checkForChanges(Collections.singleton(file));
+  }
+
+  @Test
+  public void testMonitorModifyFileAttributes() throws Exception {
+    Path file = newTempFile("test.txt");
+    FileTime lastModified = Files.getLastModifiedTime(file);
+    delegate.startMonitorPath(tempRoot, queue);
+    Files.setLastModifiedTime(file, 
+        FileTime.fromMillis(lastModified.toMillis() + 10000L));
+    // Modifying a file shows up as a change to that file.
+    checkForChanges(Collections.singleton(file));
+  }
+
+  @Test
+  public void testMonitorRenameDir() throws Exception {
+    Path dir = newTempDir("dir1");
+    Path newDir = dir.resolveSibling("newName.dir");
+    delegate.startMonitorPath(tempRoot, queue);
+    Files.move(dir, newDir, StandardCopyOption.ATOMIC_MOVE);
+    // Renaming a directory shows up as a change to its old name, its new name,
+    // and its parent.
+    checkForChanges(Sets.newHashSet(tempRoot, dir));
+  }
+
+  @Test
+  public void testMonitorMoveDir() throws Exception {
+    Path dir1 = newTempDir("dir1");
+    Path dir2 = newTempDir("dir2");
+    Path dir1dir2 = dir1.resolve(dir2.getFileName());
+    delegate.startMonitorPath(tempRoot, queue);
+    Files.move(dir2, dir1dir2);
+    // Moving a file shows up as a change to its old name, its new name,
+    // its old parent, and its new parent.
+    checkForChanges(Sets.newHashSet(tempRoot, dir1, dir2));
+  }
+
+  @Test
+  public void testMonitorChangesInSubDirs() throws Exception {
+    Path dir = newTempDir("testDir");
+    Path file = newTempFile(dir, "test.txt");
+    delegate.startMonitorPath(tempRoot, queue);
+    Files.write(file, "Hello World".getBytes("UTF-8"));
+    // Modifying a file shows up as a change to that file.
+    checkForChanges(Collections.singleton(file));
+  }
+
+  private void checkForChanges(Set<Path> expected) throws Exception {
+    // Collect up the changes.  Adapted from BlockingQueueBatcher.take(),
+    // but without infinite initial wait.
+    Set<Path> changes = Sets.newHashSet();
+    int maxBatchSize = expected.size();
+    long maxLatencyMillis = 10000;
+    long currentTime = System.currentTimeMillis();
+    long stopBatchTime = currentTime + maxLatencyMillis;
+
+    while (currentTime < stopBatchTime && changes.size() < maxBatchSize) {
+      // Block until an item is in the queue or the batch timeout expires.
+      Path path =
+          queue.poll(stopBatchTime - currentTime, TimeUnit.MILLISECONDS);
+      if (path == null) {
+        // Timeout occurred.
+        break;
+      }
+      changes.add(path);
+      queue.drainTo(changes);
+      currentTime = System.currentTimeMillis();
+    }
+
+    // Now verify that the changes we got were the ones that were expected.
+    assertEquals(expected, changes);
+  }
+}