/*
 * 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.hadoop.fs.s3a.s3guard;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;

import com.google.common.collect.Sets;
import org.junit.After;
import org.junit.Assert;
import org.junit.Assume;
import org.junit.Before;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileStatus;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.fs.RemoteIterator;
import org.apache.hadoop.fs.permission.FsPermission;
import org.apache.hadoop.fs.s3a.S3ATestUtils;
import org.apache.hadoop.fs.s3a.Tristate;
import org.apache.hadoop.io.IOUtils;

/**
 * Main test class for MetadataStore implementations.
 * Implementations should each create a test by subclassing this and
 * overriding {@link #createContract()}.
 * If your implementation may return missing results for recently set paths,
 * override {@link MetadataStoreTestBase#allowMissing()}.
 */
public abstract class MetadataStoreTestBase extends Assert {

  private static final Logger LOG =
      LoggerFactory.getLogger(MetadataStoreTestBase.class);

  /** Some dummy values for sanity-checking FileStatus contents. */
  static final long BLOCK_SIZE = 32 * 1024 * 1024;
  static final int REPLICATION = 1;
  static final FsPermission PERMISSION = new FsPermission((short)0755);
  static final String OWNER = "bob";
  static final String GROUP = "uncles";
  private final long accessTime = System.currentTimeMillis();
  private final long modTime = accessTime - 5000;

  /**
   * Each test should override this.  Will use a new Configuration instance.
   * @return Contract which specifies the MetadataStore under test plus config.
   */
  public abstract AbstractMSContract createContract() throws IOException;

  /**
   * Each test should override this.
   * @param conf Base configuration instance to use.
   * @return Contract which specifies the MetadataStore under test plus config.
   */
  public abstract AbstractMSContract createContract(Configuration conf)
      throws IOException;

  /**
   * Tests assume that implementations will return recently set results.  If
   * your implementation does not always hold onto metadata (e.g. LRU or
   * time-based expiry) you can override this to return false.
   * @return true if the test should succeed when null results are returned
   *  from the MetadataStore under test.
   */
  public boolean allowMissing() {
    return false;
  }

  /**
   * Pruning is an optional feature for metadata store implementations.
   * Tests will only check that functionality if it is expected to work.
   * @return true if the test should expect pruning to work.
   */
  public boolean supportsPruning() {
    return true;
  }

  /** The MetadataStore contract used to test against. */
  private AbstractMSContract contract;

  private MetadataStore ms;

  /**
   * @return reference to the test contract.
   */
  protected AbstractMSContract getContract() {
    return contract;
  }

  @Before
  public void setUp() throws Exception {
    LOG.debug("== Setup. ==");
    contract = createContract();
    ms = contract.getMetadataStore();
    assertNotNull("null MetadataStore", ms);
    assertNotNull("null FileSystem", contract.getFileSystem());
    ms.initialize(contract.getFileSystem());
  }

  @After
  public void tearDown() throws Exception {
    LOG.debug("== Tear down. ==");
    if (ms != null) {
      try {
        ms.destroy();
      } catch (Exception e) {
        LOG.warn("Failed to destroy tables in teardown", e);
      }
      IOUtils.closeStream(ms);
      ms = null;
    }
  }

  /**
   * Helper function for verifying DescendantsIterator and
   * MetadataStoreListFilesIterator behavior.
   * @param createNodes List of paths to create
   * @param checkNodes List of paths that the iterator should return
   */
  private void doTestDescendantsIterator(
      Class implementation, String[] createNodes,
      String[] checkNodes) throws Exception {
    // we set up the example file system tree in metadata store
    for (String pathStr : createNodes) {
      final FileStatus status = pathStr.contains("file")
          ? basicFileStatus(strToPath(pathStr), 100, false)
          : basicFileStatus(strToPath(pathStr), 0, true);
      ms.put(new PathMetadata(status));
    }

    final PathMetadata rootMeta = new PathMetadata(makeDirStatus("/"));
    RemoteIterator<FileStatus> iterator;
    if (implementation == DescendantsIterator.class) {
      iterator = new DescendantsIterator(ms, rootMeta);
    } else if (implementation == MetadataStoreListFilesIterator.class) {
      iterator = new MetadataStoreListFilesIterator(ms, rootMeta, false);
    } else {
      throw new UnsupportedOperationException("Unrecognized class");
    }

    final Set<String> actual = new HashSet<>();
    while (iterator.hasNext()) {
      final Path p = iterator.next().getPath();
      actual.add(Path.getPathWithoutSchemeAndAuthority(p).toString());
    }
    LOG.info("We got {} by iterating DescendantsIterator", actual);

    if (!allowMissing()) {
      assertEquals(Sets.newHashSet(checkNodes), actual);
    }
  }

  /**
   * Test that we can get the whole sub-tree by iterating DescendantsIterator.
   *
   * The tree is similar to or same as the example in code comment.
   */
  @Test
  public void testDescendantsIterator() throws Exception {
    final String[] tree = new String[] {
        "/dir1",
        "/dir1/dir2",
        "/dir1/dir3",
        "/dir1/dir2/file1",
        "/dir1/dir2/file2",
        "/dir1/dir3/dir4",
        "/dir1/dir3/dir5",
        "/dir1/dir3/dir4/file3",
        "/dir1/dir3/dir5/file4",
        "/dir1/dir3/dir6"
    };
    doTestDescendantsIterator(DescendantsIterator.class,
        tree, tree);
  }

  /**
   * Test that we can get the correct subset of the tree with
   * MetadataStoreListFilesIterator.
   *
   * The tree is similar to or same as the example in code comment.
   */
  @Test
  public void testMetadataStoreListFilesIterator() throws Exception {
    final String[] wholeTree = new String[] {
        "/dir1",
        "/dir1/dir2",
        "/dir1/dir3",
        "/dir1/dir2/file1",
        "/dir1/dir2/file2",
        "/dir1/dir3/dir4",
        "/dir1/dir3/dir5",
        "/dir1/dir3/dir4/file3",
        "/dir1/dir3/dir5/file4",
        "/dir1/dir3/dir6"
    };
    final String[] leafNodes = new String[] {
        "/dir1/dir2/file1",
        "/dir1/dir2/file2",
        "/dir1/dir3/dir4/file3",
        "/dir1/dir3/dir5/file4"
    };
    doTestDescendantsIterator(MetadataStoreListFilesIterator.class, wholeTree,
        leafNodes);
  }

  @Test
  public void testPutNew() throws Exception {
    /* create three dirs /da1, /da2, /da3 */
    createNewDirs("/da1", "/da2", "/da3");

    /* It is caller's responsibility to set up ancestor entries beyond the
     * containing directory.  We only track direct children of the directory.
     * Thus this will not affect entry for /da1.
     */
    ms.put(new PathMetadata(makeFileStatus("/da1/db1/fc1", 100)));

    assertEmptyDirs("/da2", "/da3");
    assertDirectorySize("/da1/db1", 1);

    /* Check contents of dir status. */
    PathMetadata dirMeta = ms.get(strToPath("/da1"));
    if (!allowMissing() || dirMeta != null) {
      verifyDirStatus(dirMeta.getFileStatus());
    }

    /* This already exists, and should silently replace it. */
    ms.put(new PathMetadata(makeDirStatus("/da1/db1")));

    /* If we had putNew(), and used it above, this would be empty again. */
    assertDirectorySize("/da1", 1);

    assertEmptyDirs("/da2", "/da3");

    /* Ensure new files update correct parent dirs. */
    ms.put(new PathMetadata(makeFileStatus("/da1/db1/fc1", 100)));
    ms.put(new PathMetadata(makeFileStatus("/da1/db1/fc2", 200)));
    assertDirectorySize("/da1", 1);
    assertDirectorySize("/da1/db1", 2);
    assertEmptyDirs("/da2", "/da3");
    PathMetadata meta = ms.get(strToPath("/da1/db1/fc2"));
    if (!allowMissing() || meta != null) {
      assertNotNull("Get file after put new.", meta);
      verifyFileStatus(meta.getFileStatus(), 200);
    }
  }

  @Test
  public void testPutOverwrite() throws Exception {
    final String filePath = "/a1/b1/c1/some_file";
    final String dirPath = "/a1/b1/c1/d1";
    ms.put(new PathMetadata(makeFileStatus(filePath, 100)));
    ms.put(new PathMetadata(makeDirStatus(dirPath)));
    PathMetadata meta = ms.get(strToPath(filePath));
    if (!allowMissing() || meta != null) {
      verifyFileStatus(meta.getFileStatus(), 100);
    }

    ms.put(new PathMetadata(basicFileStatus(strToPath(filePath), 9999, false)));
    meta = ms.get(strToPath(filePath));
    if (!allowMissing() || meta != null) {
      verifyFileStatus(meta.getFileStatus(), 9999);
    }
  }

  @Test
  public void testRootDirPutNew() throws Exception {
    Path rootPath = strToPath("/");

    ms.put(new PathMetadata(makeFileStatus("/file1", 100)));
    DirListingMetadata dir = ms.listChildren(rootPath);
    if (!allowMissing() || dir != null) {
      assertNotNull("Root dir cached", dir);
      assertFalse("Root not fully cached", dir.isAuthoritative());
      assertNotNull("have root dir file listing", dir.getListing());
      assertEquals("One file in root dir", 1, dir.getListing().size());
      assertEquals("file1 in root dir", strToPath("/file1"),
          dir.getListing().iterator().next().getFileStatus().getPath());
    }
  }

  @Test
  public void testDelete() throws Exception {
    setUpDeleteTest();

    ms.delete(strToPath("/ADirectory1/db1/file2"));

    /* Ensure delete happened. */
    assertDirectorySize("/ADirectory1/db1", 1);
    PathMetadata meta = ms.get(strToPath("/ADirectory1/db1/file2"));
    assertTrue("File deleted", meta == null || meta.isDeleted());
  }

  @Test
  public void testDeleteSubtree() throws Exception {
    deleteSubtreeHelper("");
  }

  @Test
  public void testDeleteSubtreeHostPath() throws Exception {
    deleteSubtreeHelper(contract.getFileSystem().getUri().toString());
  }

  private void deleteSubtreeHelper(String pathPrefix) throws Exception {

    String p = pathPrefix;
    setUpDeleteTest(p);
    createNewDirs(p + "/ADirectory1/db1/dc1", p + "/ADirectory1/db1/dc1/dd1");
    ms.put(new PathMetadata(
        makeFileStatus(p + "/ADirectory1/db1/dc1/dd1/deepFile", 100)));
    if (!allowMissing()) {
      assertCached(p + "/ADirectory1/db1");
    }
    ms.deleteSubtree(strToPath(p + "/ADirectory1/db1/"));

    assertEmptyDirectory(p + "/ADirectory1");
    assertDeleted(p + "/ADirectory1/db1");
    assertDeleted(p + "/ADirectory1/file1");
    assertDeleted(p + "/ADirectory1/file2");
    assertDeleted(p + "/ADirectory1/db1/dc1/dd1/deepFile");
    assertEmptyDirectory(p + "/ADirectory2");
  }


  /*
   * Some implementations might not support this.  It was useful to test
   * correctness of the LocalMetadataStore implementation, but feel free to
   * override this to be a no-op.
   */
  @Test
  public void testDeleteRecursiveRoot() throws Exception {
    setUpDeleteTest();

    ms.deleteSubtree(strToPath("/"));
    assertDeleted("/ADirectory1");
    assertDeleted("/ADirectory2");
    assertDeleted("/ADirectory2/db1");
    assertDeleted("/ADirectory2/db1/file1");
    assertDeleted("/ADirectory2/db1/file2");
  }

  @Test
  public void testDeleteNonExisting() throws Exception {
    // Path doesn't exist, but should silently succeed
    ms.delete(strToPath("/bobs/your/uncle"));

    // Ditto.
    ms.deleteSubtree(strToPath("/internets"));
  }


  private void setUpDeleteTest() throws IOException {
    setUpDeleteTest("");
  }

  private void setUpDeleteTest(String prefix) throws IOException {
    createNewDirs(prefix + "/ADirectory1", prefix + "/ADirectory2",
        prefix + "/ADirectory1/db1");
    ms.put(new PathMetadata(makeFileStatus(prefix + "/ADirectory1/db1/file1",
        100)));
    ms.put(new PathMetadata(makeFileStatus(prefix + "/ADirectory1/db1/file2",
        100)));

    PathMetadata meta = ms.get(strToPath(prefix + "/ADirectory1/db1/file2"));
    if (!allowMissing() || meta != null) {
      assertNotNull("Found test file", meta);
      assertDirectorySize(prefix + "/ADirectory1/db1", 2);
    }
  }

  @Test
  public void testGet() throws Exception {
    final String filePath = "/a1/b1/c1/some_file";
    final String dirPath = "/a1/b1/c1/d1";
    ms.put(new PathMetadata(makeFileStatus(filePath, 100)));
    ms.put(new PathMetadata(makeDirStatus(dirPath)));
    PathMetadata meta = ms.get(strToPath(filePath));
    if (!allowMissing() || meta != null) {
      assertNotNull("Get found file", meta);
      verifyFileStatus(meta.getFileStatus(), 100);
    }

    if (!(ms instanceof NullMetadataStore)) {
      ms.delete(strToPath(filePath));
      meta = ms.get(strToPath(filePath));
      assertTrue("Tombstone not left for deleted file", meta.isDeleted());
    }

    meta = ms.get(strToPath(dirPath));
    if (!allowMissing() || meta != null) {
      assertNotNull("Get found file (dir)", meta);
      assertTrue("Found dir", meta.getFileStatus().isDirectory());
    }

    meta = ms.get(strToPath("/bollocks"));
    assertNull("Don't get non-existent file", meta);
  }

  @Test
  public void testGetEmptyDir() throws Exception {
    final String dirPath = "/a1/b1/c1/d1";
    // Creates /a1/b1/c1/d1 as an empty dir
    setupListStatus();

    // 1. Tell MetadataStore (MS) that there are zero children
    putListStatusFiles(dirPath, true /* authoritative */
        /* zero children */);

    // 2. Request a file status for dir, including whether or not the dir
    // is empty.
    PathMetadata meta = ms.get(strToPath(dirPath), true);

    // 3. Check that either (a) the MS doesn't track whether or not it is
    // empty (which is allowed), or (b) the MS knows the dir is empty.
    if (!allowMissing() || meta != null) {
      assertNotNull("Get should find meta for dir", meta);
      assertNotEquals("Dir is empty or unknown", Tristate.FALSE,
          meta.isEmptyDirectory());
    }
  }

  @Test
  public void testGetNonEmptyDir() throws Exception {
    final String dirPath = "/a1/b1/c1";
    // Creates /a1/b1/c1 as an non-empty dir
    setupListStatus();

    // Request a file status for dir, including whether or not the dir
    // is empty.
    PathMetadata meta = ms.get(strToPath(dirPath), true);

    // MetadataStore knows /a1/b1/c1 has at least one child.  It is valid
    // for it to answer either (a) UNKNOWN: the MS doesn't track whether
    // or not the dir is empty, or (b) the MS knows the dir is non-empty.
    if (!allowMissing() || meta != null) {
      assertNotNull("Get should find meta for dir", meta);
      assertNotEquals("Dir is non-empty or unknown", Tristate.TRUE,
          meta.isEmptyDirectory());
    }
  }

  @Test
  public void testGetDirUnknownIfEmpty() throws Exception {
    final String dirPath = "/a1/b1/c1/d1";
    // 1. Create /a1/b1/c1/d1 as an empty dir, but do not tell MetadataStore
    // (MS) whether or not it has any children.
    setupListStatus();

    // 2. Request a file status for dir, including whether or not the dir
    // is empty.
    PathMetadata meta = ms.get(strToPath(dirPath), true);

    // 3. Assert MS reports isEmptyDir as UNKONWN: We haven't told MS
    // whether or not the directory has any children.
    if (!allowMissing() || meta != null) {
      assertNotNull("Get should find meta for dir", meta);
      assertEquals("Dir empty is unknown", Tristate.UNKNOWN,
          meta.isEmptyDirectory());
    }
  }

  @Test
  public void testListChildren() throws Exception {
    setupListStatus();

    DirListingMetadata dirMeta;
    dirMeta = ms.listChildren(strToPath("/"));
    if (!allowMissing()) {
      assertNotNull(dirMeta);
        /* Cache has no way of knowing it has all entries for root unless we
         * specifically tell it via put() with
         * DirListingMetadata.isAuthoritative = true */
      assertFalse("Root dir is not cached, or partially cached",
          dirMeta.isAuthoritative());
      assertListingsEqual(dirMeta.getListing(), "/a1", "/a2");
    }

    dirMeta = ms.listChildren(strToPath("/a1"));
    if (!allowMissing() || dirMeta != null) {
      dirMeta = dirMeta.withoutTombstones();
      assertListingsEqual(dirMeta.getListing(), "/a1/b1", "/a1/b2");
    }

    // TODO HADOOP-14756 instrument MetadataStore for asserting & testing
    dirMeta = ms.listChildren(strToPath("/a1/b1"));
    if (!allowMissing() || dirMeta != null) {
      assertListingsEqual(dirMeta.getListing(), "/a1/b1/file1", "/a1/b1/file2",
          "/a1/b1/c1");
    }
  }

  @Test
  public void testDirListingRoot() throws Exception {
    commonTestPutListStatus("/");
  }

  @Test
  public void testPutDirListing() throws Exception {
    commonTestPutListStatus("/a");
  }

  @Test
  public void testInvalidListChildren() throws Exception {
    setupListStatus();
    assertNull("missing path returns null",
        ms.listChildren(strToPath("/a1/b1x")));
  }

  @Test
  public void testMove() throws Exception {
    // Create test dir structure
    createNewDirs("/a1", "/a2", "/a3");
    createNewDirs("/a1/b1", "/a1/b2");
    putListStatusFiles("/a1/b1", false, "/a1/b1/file1", "/a1/b1/file2");

    // Assert root listing as expected
    Collection<PathMetadata> entries;
    DirListingMetadata dirMeta = ms.listChildren(strToPath("/"));
    if (!allowMissing() || dirMeta != null) {
      dirMeta = dirMeta.withoutTombstones();
      assertNotNull("Listing root", dirMeta);
      entries = dirMeta.getListing();
      assertListingsEqual(entries, "/a1", "/a2", "/a3");
    }

    // Assert src listing as expected
    dirMeta = ms.listChildren(strToPath("/a1/b1"));
    if (!allowMissing() || dirMeta != null) {
      assertNotNull("Listing /a1/b1", dirMeta);
      entries = dirMeta.getListing();
      assertListingsEqual(entries, "/a1/b1/file1", "/a1/b1/file2");
    }

    // Do the move(): rename(/a1/b1, /b1)
    Collection<Path> srcPaths = Arrays.asList(strToPath("/a1/b1"),
        strToPath("/a1/b1/file1"), strToPath("/a1/b1/file2"));

    ArrayList<PathMetadata> destMetas = new ArrayList<>();
    destMetas.add(new PathMetadata(makeDirStatus("/b1")));
    destMetas.add(new PathMetadata(makeFileStatus("/b1/file1", 100)));
    destMetas.add(new PathMetadata(makeFileStatus("/b1/file2", 100)));
    ms.move(srcPaths, destMetas);

    // Assert src is no longer there
    dirMeta = ms.listChildren(strToPath("/a1"));
    if (!allowMissing() || dirMeta != null) {
      assertNotNull("Listing /a1", dirMeta);
      entries = dirMeta.withoutTombstones().getListing();
      assertListingsEqual(entries, "/a1/b2");
    }

    PathMetadata meta = ms.get(strToPath("/a1/b1/file1"));
    assertTrue("Src path deleted", meta == null || meta.isDeleted());

    // Assert dest looks right
    meta = ms.get(strToPath("/b1/file1"));
    if (!allowMissing() || meta != null) {
      assertNotNull("dest file not null", meta);
      verifyFileStatus(meta.getFileStatus(), 100);
    }

    dirMeta = ms.listChildren(strToPath("/b1"));
    if (!allowMissing() || dirMeta != null) {
      assertNotNull("dest listing not null", dirMeta);
      entries = dirMeta.getListing();
      assertListingsEqual(entries, "/b1/file1", "/b1/file2");
    }
  }

  /**
   * Test that the MetadataStore differentiates between the same path in two
   * different buckets.
   */
  @Test
  public void testMultiBucketPaths() throws Exception {
    String p1 = "s3a://bucket-a/path1";
    String p2 = "s3a://bucket-b/path2";

    // Make sure we start out empty
    PathMetadata meta = ms.get(new Path(p1));
    assertNull("Path should not be present yet.", meta);
    meta = ms.get(new Path(p2));
    assertNull("Path2 should not be present yet.", meta);

    // Put p1, assert p2 doesn't match
    ms.put(new PathMetadata(makeFileStatus(p1, 100)));
    meta = ms.get(new Path(p2));
    assertNull("Path 2 should not match path 1.", meta);

    // Make sure delete is correct as well
    if (!allowMissing()) {
      ms.delete(new Path(p2));
      meta = ms.get(new Path(p1));
      assertNotNull("Path should not have been deleted", meta);
    }
    ms.delete(new Path(p1));
  }

  @Test
  public void testPruneFiles() throws Exception {
    Assume.assumeTrue(supportsPruning());
    createNewDirs("/pruneFiles");

    long oldTime = getTime();
    ms.put(new PathMetadata(makeFileStatus("/pruneFiles/old", 1, oldTime,
        oldTime)));
    DirListingMetadata ls2 = ms.listChildren(strToPath("/pruneFiles"));
    if (!allowMissing()) {
      assertListingsEqual(ls2.getListing(), "/pruneFiles/old");
    }

    // It's possible for the Local implementation to get from /pruneFiles/old's
    // modification time to here in under 1ms, causing it to not get pruned
    Thread.sleep(1);
    long cutoff = System.currentTimeMillis();
    long newTime = getTime();
    ms.put(new PathMetadata(makeFileStatus("/pruneFiles/new", 1, newTime,
        newTime)));

    DirListingMetadata ls;
    ls = ms.listChildren(strToPath("/pruneFiles"));
    if (!allowMissing()) {
      assertListingsEqual(ls.getListing(), "/pruneFiles/new",
          "/pruneFiles/old");
    }
    ms.prune(cutoff);
    ls = ms.listChildren(strToPath("/pruneFiles"));
    if (allowMissing()) {
      assertDeleted("/pruneFiles/old");
    } else {
      assertListingsEqual(ls.getListing(), "/pruneFiles/new");
    }
  }

  @Test
  public void testPruneDirs() throws Exception {
    Assume.assumeTrue(supportsPruning());

    // We only test that files, not dirs, are removed during prune.
    // We specifically allow directories to remain, as it is more robust
    // for DynamoDBMetadataStore's prune() implementation: If a
    // file was created in a directory while it was being pruned, it would
    // violate the invariant that all ancestors of a file exist in the table.

    createNewDirs("/pruneDirs/dir");

    long oldTime = getTime();
    ms.put(new PathMetadata(makeFileStatus("/pruneDirs/dir/file",
        1, oldTime, oldTime)));

    // It's possible for the Local implementation to get from the old
    // modification time to here in under 1ms, causing it to not get pruned
    Thread.sleep(1);
    long cutoff = getTime();

    ms.prune(cutoff);

    assertDeleted("/pruneDirs/dir/file");
  }

  @Test
  public void testPruneUnsetsAuthoritative() throws Exception {
    String rootDir = "/unpruned-root-dir";
    String grandparentDir = rootDir + "/pruned-grandparent-dir";
    String parentDir = grandparentDir + "/pruned-parent-dir";
    String staleFile = parentDir + "/stale-file";
    String freshFile = rootDir + "/fresh-file";
    String[] directories = {rootDir, grandparentDir, parentDir};

    createNewDirs(rootDir, grandparentDir, parentDir);
    long time = System.currentTimeMillis();
    ms.put(new PathMetadata(
        new FileStatus(0, false, 0, 0, time - 1, strToPath(staleFile)),
        Tristate.FALSE, false));
    ms.put(new PathMetadata(
        new FileStatus(0, false, 0, 0, time + 1, strToPath(freshFile)),
        Tristate.FALSE, false));

    ms.prune(time);
    DirListingMetadata listing;
    for (String directory : directories) {
      Path path = strToPath(directory);
      if (ms.get(path) != null) {
        listing = ms.listChildren(path);
        assertFalse(listing.isAuthoritative());
      }
    }
  }

  /*
   * Helper functions.
   */

  /** Modifies paths input array and returns it. */
  private String[] buildPathStrings(String parent, String... paths)
      throws IOException {
    for (int i = 0; i < paths.length; i++) {
      Path p = new Path(strToPath(parent), paths[i]);
      paths[i] = p.toString();
    }
    return paths;
  }

  private void commonTestPutListStatus(final String parent) throws IOException {
    putListStatusFiles(parent, true, buildPathStrings(parent, "file1", "file2",
        "file3"));
    DirListingMetadata dirMeta = ms.listChildren(strToPath(parent));
    if (!allowMissing() || dirMeta != null) {
      dirMeta = dirMeta.withoutTombstones();
      assertNotNull("list after putListStatus", dirMeta);
      Collection<PathMetadata> entries = dirMeta.getListing();
      assertNotNull("listStatus has entries", entries);
      assertListingsEqual(entries,
          buildPathStrings(parent, "file1", "file2", "file3"));
    }
  }

  private void setupListStatus() throws IOException {
    createNewDirs("/a1", "/a2", "/a1/b1", "/a1/b2", "/a1/b1/c1",
        "/a1/b1/c1/d1");
    ms.put(new PathMetadata(makeFileStatus("/a1/b1/file1", 100)));
    ms.put(new PathMetadata(makeFileStatus("/a1/b1/file2", 100)));
  }

  private void assertListingsEqual(Collection<PathMetadata> listing,
      String ...pathStrs) throws IOException {
    Set<Path> a = new HashSet<>();
    for (PathMetadata meta : listing) {
      a.add(meta.getFileStatus().getPath());
    }

    Set<Path> b = new HashSet<>();
    for (String ps : pathStrs) {
      b.add(strToPath(ps));
    }
    assertEquals("Same set of files", b, a);
  }

  private void putListStatusFiles(String dirPath, boolean authoritative,
      String... filenames) throws IOException {
    ArrayList<PathMetadata> metas = new ArrayList<>(filenames .length);
    for (String filename : filenames) {
      metas.add(new PathMetadata(makeFileStatus(filename, 100)));
    }
    DirListingMetadata dirMeta =
        new DirListingMetadata(strToPath(dirPath), metas, authoritative);
    ms.put(dirMeta);
  }

  private void createNewDirs(String... dirs)
      throws IOException {
    for (String pathStr : dirs) {
      ms.put(new PathMetadata(makeDirStatus(pathStr)));
    }
  }

  private void assertDirectorySize(String pathStr, int size)
      throws IOException {
    DirListingMetadata dirMeta = ms.listChildren(strToPath(pathStr));
    if (!allowMissing()) {
      assertNotNull("Directory " + pathStr + " in cache", dirMeta);
    }
    if (!allowMissing() || dirMeta != null) {
      dirMeta = dirMeta.withoutTombstones();
      assertEquals("Number of entries in dir " + pathStr, size,
          nonDeleted(dirMeta.getListing()).size());
    }
  }

  /** @return only file statuses which are *not* marked deleted. */
  private Collection<PathMetadata> nonDeleted(
      Collection<PathMetadata> statuses) {
    Collection<PathMetadata> currentStatuses = new ArrayList<>();
    for (PathMetadata status : statuses) {
      if (!status.isDeleted()) {
        currentStatuses.add(status);
      }
    }
    return currentStatuses;
  }

  private void assertDeleted(String pathStr) throws IOException {
    Path path = strToPath(pathStr);
    PathMetadata meta = ms.get(path);
    boolean cached = meta != null && !meta.isDeleted();
    assertFalse(pathStr + " should not be cached.", cached);
  }

  protected void assertCached(String pathStr) throws IOException {
    Path path = strToPath(pathStr);
    PathMetadata meta = ms.get(path);
    boolean cached = meta != null && !meta.isDeleted();
    assertTrue(pathStr + " should be cached.", cached);
  }

  /**
   * Convenience to create a fully qualified Path from string.
   */
  Path strToPath(String p) throws IOException {
    final Path path = new Path(p);
    assert path.isAbsolute();
    return path.makeQualified(contract.getFileSystem().getUri(), null);
  }

  private void assertEmptyDirectory(String pathStr) throws IOException {
    assertDirectorySize(pathStr, 0);
  }

  private void assertEmptyDirs(String ...dirs) throws IOException {
    for (String pathStr : dirs) {
      assertEmptyDirectory(pathStr);
    }
  }

  FileStatus basicFileStatus(Path path, int size, boolean isDir) throws
      IOException {
    return basicFileStatus(path, size, isDir, modTime, accessTime);
  }

  FileStatus basicFileStatus(Path path, int size, boolean isDir,
      long newModTime, long newAccessTime) throws IOException {
    return new FileStatus(size, isDir, REPLICATION, BLOCK_SIZE, newModTime,
        newAccessTime, PERMISSION, OWNER, GROUP, path);
  }

  private FileStatus makeFileStatus(String pathStr, int size) throws
      IOException {
    return makeFileStatus(pathStr, size, modTime, accessTime);
  }

  private FileStatus makeFileStatus(String pathStr, int size, long newModTime,
      long newAccessTime) throws IOException {
    return basicFileStatus(strToPath(pathStr), size, false,
        newModTime, newAccessTime);
  }

  void verifyFileStatus(FileStatus status, long size) {
    S3ATestUtils.verifyFileStatus(status, size, BLOCK_SIZE, modTime);
  }

  private FileStatus makeDirStatus(String pathStr) throws IOException {
    return basicFileStatus(strToPath(pathStr), 0, true, modTime, accessTime);
  }

  /**
   * Verify the directory file status. Subclass may verify additional fields.
   */
  void verifyDirStatus(FileStatus status) {
    assertTrue("Is a dir", status.isDirectory());
    assertEquals("zero length", 0, status.getLen());
  }

  long getModTime() {
    return modTime;
  }

  long getAccessTime() {
    return accessTime;
  }

  protected static long getTime() {
    return System.currentTimeMillis();
  }

}
