package org.xbib.files.ftp.fs.test;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.verification.VerificationMode;
import org.xbib.files.ftp.mock.fake.filesystem.DirectoryEntry;
import org.xbib.files.ftp.mock.fake.filesystem.FileEntry;
import org.xbib.files.ftp.mock.fake.filesystem.FileSystemEntry;
import org.xbib.files.ftp.FTPFile;
import org.xbib.files.ftp.fs.FTPFileSystem;
import org.xbib.files.ftp.fs.FTPFileSystemException;
import org.xbib.files.ftp.fs.FTPPath;
import org.xbib.files.ftp.fs.SimpleGroupPrincipal;
import org.xbib.files.ftp.fs.SimpleUserPrincipal;
import org.xbib.files.ftp.fs.test.server.SymbolicLinkEntry;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.AccessDeniedException;
import java.nio.file.AccessMode;
import java.nio.file.CopyOption;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.DirectoryStream;
import java.nio.file.DirectoryStream.Filter;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileSystemException;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.NotDirectoryException;
import java.nio.file.NotLinkException;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFileAttributes;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.instanceOf;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

public class FTPFileSystemTest extends AbstractFTPFileSystemTest {

    @Test
    public void testGetPath() {
        testGetPath("/", "/");
        testGetPath("/foo/bar", "/", "/foo", "/bar");
        testGetPath("/foo/../bar", "/foo/", "../bar");
    }

    private void testGetPath(String path, String first, String... more) {
        FTPPath expected = createPath(path);
        Path actual = getFileSystem().getPath(first, more);
        assertEquals(expected, actual);
    }

    @Test
    public void testKeepAlive() throws IOException {
        getFileSystem().keepAlive();
    }

    @Test
    public void testToUri() {
        final String prefix = getBaseUrl();

        testToUri("/", prefix + "/");
        testToUri("/foo/bar", prefix + "/foo/bar");
        testToUri("/foo/../bar", prefix + "/bar");

        testToUri("", prefix + "/home/test");
        testToUri("foo/bar", prefix + "/home/test/foo/bar");
        testToUri("foo/../bar", prefix + "/home/test/bar");
    }

    private void testToUri(String path, String expected) {
        URI expectedUri = URI.create(expected);
        URI actual = getFileSystem().toUri(createPath(path));
        assertEquals(expectedUri, actual);
    }

    @Test
    public void testToAbsolutePath() {

        testToAbsolutePath("/", "/");
        testToAbsolutePath("/foo/bar", "/foo/bar");
        testToAbsolutePath("/foo/../bar", "/foo/../bar");

        testToAbsolutePath("", "/home/test");
        testToAbsolutePath("foo/bar", "/home/test/foo/bar");
        testToAbsolutePath("foo/../bar", "/home/test/foo/../bar");
    }

    private void testToAbsolutePath(String path, String expected) {
        FTPPath expectedPath = createPath(expected);
        Path actual = getFileSystem().toAbsolutePath(createPath(path));
        assertEquals(expectedPath, actual);
    }

    @Test
    public void testToRealPathNoFollowLinks() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        addDirectory("/foo/bar");
        addDirectory("/bar");
        addFile("/home/test/foo/bar");
        FileEntry bar = addFile("/home/test/bar");

        // symbolic links
        SymbolicLinkEntry symLink = addSymLink("/hello", foo);
        addSymLink("/world", symLink);
        symLink = addSymLink("/home/test/baz", bar);
        addSymLink("/baz", symLink);

        testToRealPathNoFollowLinks("/", "/");
        testToRealPathNoFollowLinks("/foo/bar", "/foo/bar");
        testToRealPathNoFollowLinks("/foo/../bar", "/bar");

        testToRealPathNoFollowLinks("", "/home/test");
        testToRealPathNoFollowLinks("foo/bar", "/home/test/foo/bar");
        testToRealPathNoFollowLinks("foo/../bar", "/home/test/bar");

        // symbolic links
        testToRealPathNoFollowLinks("/hello", "/hello");
        testToRealPathNoFollowLinks("/world", "/world");
        testToRealPathNoFollowLinks("/home/test/baz", "/home/test/baz");
        testToRealPathNoFollowLinks("/baz", "/baz");
    }

    private void testToRealPathNoFollowLinks(String path, String expected) throws IOException {
        FTPPath expectedPath = createPath(expected);
        Path actual = getFileSystem().toRealPath(createPath(path), LinkOption.NOFOLLOW_LINKS);
        assertEquals(expectedPath, actual);
    }

    @Test
    public void testToRealPathFollowLinks() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        addDirectory("/foo/bar");
        addDirectory("/bar");
        addFile("/home/test/foo/bar");
        FileEntry bar = addFile("/home/test/bar");

        // symbolic links
        SymbolicLinkEntry symLink = addSymLink("/hello", foo);
        addSymLink("/world", symLink);
        symLink = addSymLink("/home/test/baz", bar);
        addSymLink("/baz", symLink);

        testToRealPathFollowLinks("/", "/");
        testToRealPathFollowLinks("/foo/bar", "/foo/bar");
        testToRealPathFollowLinks("/foo/../bar", "/bar");

        testToRealPathFollowLinks("", "/home/test");
        testToRealPathFollowLinks("foo/bar", "/home/test/foo/bar");
        testToRealPathFollowLinks("foo/../bar", "/home/test/bar");

        // symbolic links
        testToRealPathFollowLinks("/hello", "/foo");
        testToRealPathFollowLinks("/world", "/foo");
        testToRealPathFollowLinks("/home/test/baz", "/home/test/bar");
        testToRealPathFollowLinks("/baz", "/home/test/bar");
    }

    private void testToRealPathFollowLinks(String path, String expected) throws IOException {
        FTPPath expectedPath = createPath(expected);
        Path actual = getFileSystem().toRealPath(createPath(path));
        assertEquals(expectedPath, actual);
    }

    @Test
    public void testToRealPathBrokenLink() {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            addSymLink("/foo", new FileEntry("/bar"));
            createPath("/foo").toRealPath();
        });
    }

    @Test
    public void testToRealPathNotExisting() throws IOException {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            createPath("/foo").toRealPath();
        });
    }

    @Test
    public void testNewInputStream() throws IOException {
        addFile("/foo/bar");
        try (InputStream input = getFileSystem().newInputStream(createPath("/foo/bar"))) {
            // don't do anything with the stream, there's a separate test for that
        }
        // verify that the file system can be used after closing the stream
        getFileSystem().checkAccess(createPath("/foo/bar"));
    }

    @Test
    public void testNewInputStreamDeleteOnClose() throws IOException {
        addFile("/foo/bar");

        OpenOption[] options = {StandardOpenOption.DELETE_ON_CLOSE};
        try (InputStream input = getFileSystem().newInputStream(createPath("/foo/bar"), options)) {
            // don't do anything with the stream, there's a separate test for that
        }
        assertNull(getFileSystemEntry("/foo/bar"));
        assertEquals(0, getChildCount("/foo"));
    }

    @Test
    public void testNewInputStreamFTPFailure() throws IOException {
        Assertions.assertThrows(FTPFileSystemException.class, () -> {
            try (InputStream input = getFileSystem().newInputStream(createPath("/foo/bar"))) {
                // don't do anything with the stream, there's a separate test for that
            } finally {
                verify(getExceptionFactory()).createNewInputStreamException(eq("/foo/bar"), eq(550), anyString());
            }
        });
    }

    @Test
    public void testNewOutputStreamExisting() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        FileEntry bar = addFile("/foo/bar");

        OpenOption[] options = {StandardOpenOption.WRITE};
        try (OutputStream output = getFileSystem().newOutputStream(createPath("/foo/bar"), options)) {
            // don't do anything with the stream, there's a separate test for that
        }
        // verify that the file system can be used after closing the stream
        getFileSystem().checkAccess(createPath("/foo/bar"));

        assertSame(foo, getFileSystemEntry("/foo"));
        assertSame(bar, getFileSystemEntry("/foo/bar"));
    }

    @Test
    public void testNewOutputStreamExistingDeleteOnClose() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        FileEntry bar = addFile("/foo/bar");

        OpenOption[] options = {StandardOpenOption.DELETE_ON_CLOSE};
        try (OutputStream output = getFileSystem().newOutputStream(createPath("/foo/bar"), options)) {
            // don't do anything with the stream, there's a separate test for that
            assertSame(bar, getFileSystemEntry("/foo/bar"));
        }
        // verify that the file system can be used after closing the stream
        getFileSystem().checkAccess(createPath("/foo"));

        assertSame(foo, getFileSystemEntry("/foo"));
        assertNull(getFileSystemEntry("/foo/bar"));
        assertEquals(0, getChildCount("/foo"));
    }

    @Test
    public void testNewOutputStreamExistingCreate() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        FileEntry bar = addFile("/foo/bar");

        OpenOption[] options = {StandardOpenOption.CREATE};
        try (OutputStream output = getFileSystem().newOutputStream(createPath("/foo/bar"), options)) {
            // don't do anything with the stream, there's a separate test for that
        }
        // verify that the file system can be used after closing the stream
        getFileSystem().checkAccess(createPath("/foo/bar"));

        assertSame(foo, getFileSystemEntry("/foo"));
        assertSame(bar, getFileSystemEntry("/foo/bar"));
    }

    @Test
    public void testNewOutputStreamExistingCreateDeleteOnClose() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        FileEntry bar = addFile("/foo/bar");

        OpenOption[] options = {StandardOpenOption.CREATE, StandardOpenOption.DELETE_ON_CLOSE};
        try (OutputStream output = getFileSystem().newOutputStream(createPath("/foo/bar"), options)) {
            // don't do anything with the stream, there's a separate test for that
            assertSame(bar, getFileSystemEntry("/foo/bar"));
        }
        // verify that the file system can be used after closing the stream
        getFileSystem().checkAccess(createPath("/foo"));

        assertSame(foo, getFileSystemEntry("/foo"));
        assertNull(getFileSystemEntry("/foo/bar"));
    }

    @Test
    public void testNewOutputStreamExistingCreateNew() {
        Assertions.assertThrows(FileAlreadyExistsException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry bar = addFile("/foo/bar");
            OpenOption[] options = {StandardOpenOption.CREATE_NEW};
            try (OutputStream output = getFileSystem().newOutputStream(createPath("/foo/bar"), options)) {
                // don't do anything with the stream, there's a separate test for that
            } finally {
                // verify that the file system can be used after closing the stream
                getFileSystem().checkAccess(createPath("/foo/bar"));
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(bar, getFileSystemEntry("/foo/bar"));
            }
        });
    }

    @Test
    public void testNewOutputStreamExistingFTPFailure() {
        Assertions.assertThrows(FTPFileSystemException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry bar = addFile("/foo/bar");
            bar.setPermissionsFromString("r--r--r--");
            OpenOption[] options = {StandardOpenOption.WRITE};
            try (OutputStream input = getFileSystem().newOutputStream(createPath("/foo/bar"), options)) {
                // don't do anything with the stream, there's a separate test for that
            } finally {
                verify(getExceptionFactory()).createNewOutputStreamException(eq("/foo/bar"), eq(553), anyString(), anyList());
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(bar, getFileSystemEntry("/foo/bar"));
            }
        });
    }

    @Test
    public void testNewOutputStreamExistingFTPFailureDeleteOnClose() {
        Assertions.assertThrows(FTPFileSystemException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry bar = addFile("/foo/bar");
            bar.setPermissionsFromString("r--r--r--");
            OpenOption[] options = {StandardOpenOption.DELETE_ON_CLOSE};
            try (OutputStream input = getFileSystem().newOutputStream(createPath("/foo/bar"), options)) {
                // don't do anything with the stream, there's a separate test for that
            } finally {
                verify(getExceptionFactory()).createNewOutputStreamException(eq("/foo/bar"), eq(553), anyString(), anyList());
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(bar, getFileSystemEntry("/foo/bar"));
            }
        });
    }

    @Test
    public void testNewOutputStreamNonExistingNoCreate() throws IOException {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            OpenOption[] options = {StandardOpenOption.WRITE};
            try (OutputStream input = getFileSystem().newOutputStream(createPath("/foo/bar"), options)) {
                // don't do anything with the stream, there's a separate test for that
            } finally {
                assertSame(foo, getFileSystemEntry("/foo"));
                assertNull(getFileSystemEntry("/foo/bar"));
            }
        });
    }

    @Test
    public void testNewOutputStreamNonExistingCreate() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        OpenOption[] options = {StandardOpenOption.CREATE};
        try (OutputStream input = getFileSystem().newOutputStream(createPath("/foo/bar"), options)) {
            // don't do anything with the stream, there's a separate test for that
        } finally {
            assertSame(foo, getFileSystemEntry("/foo"));
            assertThat(getFileSystemEntry("/foo/bar"), instanceOf(FileEntry.class));
        }
    }

    @Test
    public void testNewOutputStreamNonExistingCreateDeleteOnClose() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        OpenOption[] options = {StandardOpenOption.CREATE, StandardOpenOption.DELETE_ON_CLOSE};
        try (OutputStream input = getFileSystem().newOutputStream(createPath("/foo/bar"), options)) {
            // don't do anything with the stream, there's a separate test for that
            // we can't check here that /foo/bar exists, because it will only be stored in the file system once the stream is closed
        } finally {
            assertSame(foo, getFileSystemEntry("/foo"));
            assertNull(getFileSystemEntry("/foo/bar"));
            assertEquals(0, getChildCount("/foo"));
        }
    }

    @Test
    public void testNewOutputStreamNonExistingCreateNew() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        OpenOption[] options = {StandardOpenOption.CREATE_NEW};
        try (OutputStream input = getFileSystem().newOutputStream(createPath("/foo/bar"), options)) {
            // don't do anything with the stream, there's a separate test for that
        } finally {
            assertSame(foo, getFileSystemEntry("/foo"));
            assertThat(getFileSystemEntry("/foo/bar"), instanceOf(FileEntry.class));
        }
    }

    @Test
    public void testNewOutputStreamNonExistingCreateNewDeleteOnClose() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        OpenOption[] options = {StandardOpenOption.CREATE_NEW, StandardOpenOption.DELETE_ON_CLOSE};
        try (OutputStream input = getFileSystem().newOutputStream(createPath("/foo/bar"), options)) {
            // don't do anything with the stream, there's a separate test for that
            // we can't check here that /foo/bar exists, because it will only be stored in the file system once the stream is closed
        } finally {
            assertSame(foo, getFileSystemEntry("/foo"));
            assertNull(getFileSystemEntry("/foo/bar"));
        }
    }

    @Test
    public void testNewOutputStreamDirectoryNoCreate() {
        Assertions.assertThrows(FileSystemException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            OpenOption[] options = {StandardOpenOption.WRITE};
            try (OutputStream input = getFileSystem().newOutputStream(createPath("/foo"), options)) {
                // don't do anything with the stream, there's a separate test for that
            } finally {
                verify(getExceptionFactory(), never()).createNewOutputStreamException(anyString(), anyInt(), anyString(), anyList());
                assertSame(foo, getFileSystemEntry("/foo"));
                assertEquals(0, getChildCount("/foo"));
            }
        });
    }

    @Test
    public void testNewOutputStreamDirectoryDeleteOnClose() {
        Assertions.assertThrows(FileSystemException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            OpenOption[] options = {StandardOpenOption.DELETE_ON_CLOSE};
            try (OutputStream input = getFileSystem().newOutputStream(createPath("/foo"), options)) {
                // don't do anything with the stream, there's a separate test for that
            } finally {
                verify(getExceptionFactory(), never()).createNewOutputStreamException(anyString(), anyInt(), anyString(), anyList());
                assertSame(foo, getFileSystemEntry("/foo"));
                assertEquals(0, getChildCount("/foo"));
            }
        });
    }

    @Test
    public void testNewByteChannelRead() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        FileEntry bar = addFile("/foo/bar");
        bar.setContents(new byte[1024]);

        Set<? extends OpenOption> options = EnumSet.noneOf(StandardOpenOption.class);
        try (SeekableByteChannel channel = getFileSystem().newByteChannel(createPath("/foo/bar"), options)) {
            // don't do anything with the channel, there's a separate test for that
            assertEquals(bar.getSize(), channel.size());
        }
        assertSame(foo, getFileSystemEntry("/foo"));
        assertSame(bar, getFileSystemEntry("/foo/bar"));
    }

    @Test
    public void testNewByteChannelReadNonExisting() throws IOException {
        Assertions.assertThrows(FTPFileSystemException.class, () -> {
            Set<? extends OpenOption> options = EnumSet.noneOf(StandardOpenOption.class);
            try (SeekableByteChannel channel = getFileSystem().newByteChannel(createPath("/foo/bar"), options)) {
                // don't do anything with the channel, there's a separate test for that
            } finally {
                verify(getExceptionFactory()).createNewInputStreamException(eq("/foo/bar"), eq(550), anyString());
                assertNull(getFileSystemEntry("/foo"));
                assertNull(getFileSystemEntry("/foo/bar"));
            }
        });
    }

    @Test
    public void testNewByteChannelWrite() throws IOException {
        addFile("/foo/bar");

        Set<? extends OpenOption> options = EnumSet.of(StandardOpenOption.WRITE);
        try (SeekableByteChannel channel = getFileSystem().newByteChannel(createPath("/foo/bar"), options)) {
            // don't do anything with the channel, there's a separate test for that
            assertEquals(0, channel.size());
        }
    }

    @Test
    public void testNewByteChannelWriteAppend() throws IOException {
        FileEntry bar = addFile("/foo/bar");
        bar.setContents(new byte[1024]);

        Set<? extends OpenOption> options = EnumSet.of(StandardOpenOption.WRITE, StandardOpenOption.APPEND);
        try (SeekableByteChannel channel = getFileSystem().newByteChannel(createPath("/foo/bar"), options)) {
            // don't do anything with the channel, there's a separate test for that
            assertEquals(bar.getSize(), channel.size());
        }
    }

    @Test
    public void testNewDirectoryStream() throws IOException {

        try (DirectoryStream<Path> stream = getFileSystem().newDirectoryStream(createPath("/"), AcceptAllFilter.INSTANCE)) {
            assertNotNull(stream);
            // don't do anything with the stream, there's a separate test for that
        }
    }

    @Test
    public void testNewDirectoryStreamNotExisting() {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            getFileSystem().newDirectoryStream(createPath("/foo"), AcceptAllFilter.INSTANCE);
        });
    }

    @Test
    public void testGetDirectoryStreamNotDirectory() {
        Assertions.assertThrows(NotDirectoryException.class, () -> {
            addFile("/foo");
            getFileSystem().newDirectoryStream(createPath("/foo"), AcceptAllFilter.INSTANCE);
        });
    }

    @Test
    public void testCreateDirectory() throws IOException {
        assertNull(getFileSystemEntry("/foo"));
        getFileSystem().createDirectory(createPath("/foo"));
        FileSystemEntry entry = getFileSystemEntry("/foo");
        assertThat(entry, instanceOf(DirectoryEntry.class));
    }

    @Test
    public void testCreateDirectoryFTPFailure() {
        Assertions.assertThrows(FileAlreadyExistsException.class, () -> {
            DirectoryEntry root = getDirectory("/");
            root.setPermissionsFromString("r-xr-xr-x");
            // failure: read-only parent
            try {
                getFileSystem().createDirectory(createPath("/foo"));
            } finally {
                assertNull(getFileSystemEntry("/foo"));
                verify(getExceptionFactory()).createCreateDirectoryException(eq("/foo"), eq(550), anyString());
            }
        });
    }

    @Test
    public void testDeleteNonExisting() {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            try {
                getFileSystem().delete(createPath("/foo"));
            } finally {
                verify(getExceptionFactory(), never()).createDeleteException(anyString(), anyInt(), anyString(), anyBoolean());
            }
        });
    }

    @Test
    public void testDeleteRoot() {
        Assertions.assertThrows(FTPFileSystemException.class, () -> {
            try {
                getFileSystem().delete(createPath("/"));
            } finally {
                verify(getExceptionFactory()).createDeleteException(eq("/"), eq(550), anyString(), eq(true));
            }
        });
    }

    @Test
    public void testDeleteFile() throws IOException {
        addFile("/foo/bar");
        FileSystemEntry foo = getFileSystemEntry("/foo");
        getFileSystem().delete(createPath("/foo/bar"));
        assertSame(foo, getFileSystemEntry("/foo"));
        assertNull(getFileSystemEntry("/foo/bar"));
    }

    @Test
    public void testDeleteEmptyDir() throws IOException {
        addDirectory("/foo/bar");
        FileSystemEntry foo = getFileSystemEntry("/foo");
        getFileSystem().delete(createPath("/foo/bar"));
        assertSame(foo, getFileSystemEntry("/foo"));
        assertNull(getFileSystemEntry("/foo/bar"));
    }

    @Test
    public void testDeleteFTPFailure() throws IOException {
        Assertions.assertThrows(FTPFileSystemException.class, () -> {
            addDirectory("/foo/bar/baz");
            FileSystemEntry foo = getFileSystemEntry("/foo");
            FileSystemEntry bar = getFileSystemEntry("/foo/bar");
            try {
                getFileSystem().delete(createPath("/foo/bar"));
            } finally {
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(bar, getFileSystemEntry("/foo/bar"));
                verify(getExceptionFactory()).createDeleteException(eq("/foo/bar"), eq(550), anyString(), eq(true));
            }
        });
    }

    @Test
    public void testReadSymbolicLinkToFile() throws IOException {
        FileEntry foo = addFile("/foo");
        addSymLink("/bar", foo);

        FTPPath link = getFileSystem().readSymbolicLink(createPath("/bar"));
        assertEquals(createPath("/foo"), link);
    }

    @Test
    public void testReadSymbolicLinkToDirectory() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        addSymLink("/bar", foo);

        FTPPath link = getFileSystem().readSymbolicLink(createPath("/bar"));
        assertEquals(createPath("/foo"), link);
    }

    @Test
    public void testReadSymbolicLinkToNonExistingTarget() throws IOException {
        addSymLink("/bar", new FileEntry("/foo"));

        FTPPath link = getFileSystem().readSymbolicLink(createPath("/bar"));
        assertEquals(createPath("/foo"), link);
    }

    @Test
    public void testReadSymbolicLinkNotExisting() {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            getFileSystem().readSymbolicLink(createPath("/foo"));
        });
    }

    @Test
    public void testReadSymbolicLinkNoLinkButFile() {
        Assertions.assertThrows(NotLinkException.class, () -> {
            addFile("/foo");
            getFileSystem().readSymbolicLink(createPath("/foo"));
        });
    }

    @Test
    public void testReadSymbolicLinkNoLinkButDirectory() {
        Assertions.assertThrows(NotLinkException.class, () -> {
            addDirectory("/foo");
            getFileSystem().readSymbolicLink(createPath("/foo"));
        });
    }

    @Test
    public void testCopySame() throws IOException {
        DirectoryEntry foo = addDirectory("/home/test/foo");
        DirectoryEntry bar = addDirectory("/home/test/foo/bar");

        CopyOption[] options = {};
        getFileSystem().copy(createPath("/home/test"), createPath(""), options);
        getFileSystem().copy(createPath("/home/test/foo"), createPath("foo"), options);
        getFileSystem().copy(createPath("/home/test/foo/bar"), createPath("foo/bar"), options);

        assertSame(foo, getFileSystemEntry("/home/test/foo"));
        assertSame(bar, getFileSystemEntry("/home/test/foo/bar"));
        assertEquals(0, getChildCount("/home/test/foo/bar"));
    }

    @Test
    public void testCopyNonExisting() throws IOException {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            CopyOption[] options = {};
            try {
                getFileSystem().copy(createPath("/foo/bar"), createPath("/foo/baz"), options);
            } finally {
                assertSame(foo, getFileSystemEntry("/foo"));
                assertEquals(0, getChildCount("/foo"));
            }
        });
    }

    @Test
    public void testCopyFTPFailure() {
        Assertions.assertThrows(FTPFileSystemException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry bar = addFile("/foo/bar");
            CopyOption[] options = {};
            try {
                getFileSystem().copy(createPath("/foo/bar"), createPath("/baz/bar"), options);
            } finally {
                verify(getExceptionFactory()).createNewOutputStreamException(eq("/baz/bar"), eq(553), anyString(), anyList());
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(bar, getFileSystemEntry("/foo/bar"));
                assertNull(getFileSystemEntry("/baz"));
                assertNull(getFileSystemEntry("/baz/bar"));
            }
        });
    }

    @Test
    public void testCopyRoot() throws IOException {
        // copying a directory (including the root) will not copy its contents, so copying the root is allowed
        DirectoryEntry foo = addDirectory("/foo");

        CopyOption[] options = {};
        getFileSystem().copy(createPath("/"), createPath("/foo/bar"), options);

        assertSame(foo, getFileSystemEntry("/foo"));

        DirectoryEntry bar = getDirectory("/foo/bar");

        assertNotSame(getDirectory("/"), bar);
        assertEquals(0, getChildCount("/foo/bar"));
    }

    @Test
    public void testCopyReplaceFile() {
        Assertions.assertThrows(FileAlreadyExistsException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry bar = addFile("/foo/bar");
            FileEntry baz = addFile("/baz");

            CopyOption[] options = {};
            try {
                getFileSystem().copy(createPath("/baz"), createPath("/foo/bar"), options);
            } finally {
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(bar, getFileSystemEntry("/foo/bar"));
                assertSame(baz, getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testCopyReplaceFileAllowed() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        FileEntry bar = addFile("/foo/bar");
        FileEntry baz = addFile("/baz");

        CopyOption[] options = {StandardCopyOption.REPLACE_EXISTING};
        getFileSystem().copy(createPath("/baz"), createPath("/foo/bar"), options);

        assertSame(foo, getFileSystemEntry("/foo"));
        assertThat(getFileSystemEntry("/foo/bar"), instanceOf(FileEntry.class));
        // permissions are dropped during the delete/recreate
        assertEqualsMinusPath(bar, getFileSystemEntry("/foo/bar"), false);
        assertNotSame(baz, getFileSystemEntry("/foo/bar"));
    }

    @Test
    public void testCopyReplaceNonEmptyDir() {
        Assertions.assertThrows(FileAlreadyExistsException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry bar = addFile("/foo/bar");
            FileEntry baz = addFile("/baz");

            CopyOption[] options = {};
            try {
                getFileSystem().copy(createPath("/baz"), createPath("/foo"), options);
            } finally {
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(bar, getFileSystemEntry("/foo/bar"));
                assertSame(baz, getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testCopyReplaceNonEmptyDirAllowed() {
        Assertions.assertThrows(FTPFileSystemException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry bar = addFile("/foo/bar");
            FileEntry baz = addFile("/baz");

            CopyOption[] options = {StandardCopyOption.REPLACE_EXISTING};
            try {
                getFileSystem().copy(createPath("/baz"), createPath("/foo"), options);
            } finally {
                verify(getExceptionFactory()).createDeleteException(eq("/foo"), eq(550), anyString(), eq(true));
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(bar, getFileSystemEntry("/foo/bar"));
                assertSame(baz, getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testCopyReplaceEmptyDir() {
        Assertions.assertThrows(FileAlreadyExistsException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry baz = addFile("/baz");

            CopyOption[] options = {};
            try {
                getFileSystem().copy(createPath("/baz"), createPath("/foo"), options);
            } finally {
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(baz, getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testCopyReplaceEmptyDirAllowed() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        DirectoryEntry baz = addDirectory("/baz");

        CopyOption[] options = {StandardCopyOption.REPLACE_EXISTING};
        getFileSystem().copy(createPath("/baz"), createPath("/foo"), options);

        assertThat(getFileSystemEntry("/foo"), instanceOf(DirectoryEntry.class));
        assertNotSame(foo, getFileSystemEntry("/foo"));
        assertNotSame(baz, getFileSystemEntry("/foo"));
    }

    @Test
    public void testCopyFile() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        FileEntry baz = addFile("/baz");

        baz.setOwner("root");

        CopyOption[] options = {};
        getFileSystem().copy(createPath("/baz"), createPath("/foo/bar"), options);

        assertThat(getFileSystemEntry("/foo/bar"), instanceOf(FileEntry.class));
        assertSame(foo, getFileSystemEntry("/foo"));
        assertNotSame(baz, getFileSystemEntry("/foo/bar"));
        assertSame(baz, getFileSystemEntry("/baz"));
        assertNotEquals(baz.getOwner(), getFileSystemEntry("/foo/bar").getOwner());
    }

    @Test
    public void testCopyFileMultipleConnections() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        FileEntry baz = addFile("/baz");

        baz.setOwner("root");

        CopyOption[] options = {};
        FTPFileSystem fs = getMultiClientFileSystem();
        fs.copy(createPath(fs, "/baz"), createPath(fs, "/foo/bar"), options);

        assertThat(getFileSystemEntry("/foo/bar"), instanceOf(FileEntry.class));
        assertSame(foo, getFileSystemEntry("/foo"));
        assertNotSame(baz, getFileSystemEntry("/foo/bar"));
        assertSame(baz, getFileSystemEntry("/baz"));
        assertNotEquals(baz.getOwner(), getFileSystemEntry("/foo/bar").getOwner());
    }

    @Test
    public void testCopyEmptyDir() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        DirectoryEntry baz = addDirectory("/baz");

        baz.setOwner("root");

        CopyOption[] options = {};
        getFileSystem().copy(createPath("/baz"), createPath("/foo/bar"), options);

        assertThat(getFileSystemEntry("/foo/bar"), instanceOf(DirectoryEntry.class));
        assertSame(foo, getFileSystemEntry("/foo"));
        assertNotSame(baz, getFileSystemEntry("/foo/bar"));
        assertSame(baz, getFileSystemEntry("/baz"));

        DirectoryEntry bar = getDirectory("/foo/bar");
        assertEquals(0, getChildCount("/foo/bar"));
        assertNotEquals(baz.getOwner(), bar.getOwner());
    }

    @Test
    public void testCopyNonEmptyDir() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        DirectoryEntry baz = addDirectory("/baz");
        addFile("/baz/qux");

        baz.setOwner("root");

        CopyOption[] options = {};
        getFileSystem().copy(createPath("/baz"), createPath("/foo/bar"), options);

        assertThat(getFileSystemEntry("/foo/bar"), instanceOf(DirectoryEntry.class));
        assertSame(foo, getFileSystemEntry("/foo"));
        assertNotSame(baz, getFileSystemEntry("/foo/bar"));
        assertSame(baz, getFileSystemEntry("/baz"));

        DirectoryEntry bar = getDirectory("/foo/bar");
        assertEquals(0, getChildCount("/foo/bar"));
        assertNotEquals(baz.getOwner(), bar.getOwner());
    }

    @Test
    public void testCopyReplaceFileDifferentFileSystems() throws IOException {
        Assertions.assertThrows(FileAlreadyExistsException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry bar = addFile("/foo/bar");
            FileEntry baz = addFile("/baz");

            CopyOption[] options = {};
            try {
                getFileSystem().copy(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo/bar"), options);
            } finally {
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(bar, getFileSystemEntry("/foo/bar"));
                assertSame(baz, getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testCopyReplaceFileAllowedDifferentFileSystems() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        FileEntry bar = addFile("/foo/bar");
        FileEntry baz = addFile("/baz");

        CopyOption[] options = {StandardCopyOption.REPLACE_EXISTING};
        getFileSystem().copy(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo/bar"), options);

        assertSame(foo, getFileSystemEntry("/foo"));
        assertThat(getFileSystemEntry("/foo/bar"), instanceOf(FileEntry.class));
        // permissions are dropped during the copy/delete
        assertEqualsMinusPath(bar, getFileSystemEntry("/foo/bar"), false);
        assertNotSame(baz, getFileSystemEntry("/foo/bar"));
    }

    @Test
    public void testCopyReplaceNonEmptyDirDifferentFileSystems() {
        Assertions.assertThrows(FileAlreadyExistsException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry bar = addFile("/foo/bar");
            FileEntry baz = addFile("/baz");

            CopyOption[] options = {};
            try {
                getFileSystem().copy(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo"), options);
            } finally {
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(bar, getFileSystemEntry("/foo/bar"));
                assertSame(baz, getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testCopyReplaceNonEmptyDirAllowedDifferentFileSystems() {
        Assertions.assertThrows(FTPFileSystemException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry bar = addFile("/foo/bar");
            FileEntry baz = addFile("/baz");
            CopyOption[] options = {StandardCopyOption.REPLACE_EXISTING};
            try {
                getFileSystem().copy(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo"), options);
            } finally {
                verify(getExceptionFactory()).createDeleteException(eq("/foo"), eq(550), anyString(), eq(true));
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(bar, getFileSystemEntry("/foo/bar"));
                assertSame(baz, getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testCopyReplaceEmptyDirDifferentFileSystems() {
        Assertions.assertThrows(FileAlreadyExistsException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry baz = addFile("/baz");

            CopyOption[] options = {};
            try {
                getFileSystem().copy(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo"), options);
            } finally {
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(baz, getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testCopyReplaceEmptyDirAllowedDifferentFileSystems() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        DirectoryEntry baz = addDirectory("/baz");

        CopyOption[] options = {StandardCopyOption.REPLACE_EXISTING};
        getFileSystem().copy(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo"), options);

        assertThat(getFileSystemEntry("/foo"), instanceOf(DirectoryEntry.class));
        assertNotSame(foo, getFileSystemEntry("/foo"));
        assertNotSame(baz, getFileSystemEntry("/foo"));
    }

    @Test
    public void testCopyFileDifferentFileSystems() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        FileEntry baz = addFile("/baz");

        baz.setOwner("root");

        CopyOption[] options = {};
        getFileSystem().copy(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo/bar"), options);

        assertThat(getFileSystemEntry("/foo/bar"), instanceOf(FileEntry.class));
        assertSame(foo, getFileSystemEntry("/foo"));
        assertNotSame(baz, getFileSystemEntry("/foo/bar"));
        assertSame(baz, getFileSystemEntry("/baz"));
        assertNotEquals(baz.getOwner(), getFileSystemEntry("/foo/bar").getOwner());
    }

    @Test
    public void testCopyEmptyDirDifferentFileSystems() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        DirectoryEntry baz = addDirectory("/baz");

        baz.setOwner("root");

        CopyOption[] options = {};
        getFileSystem().copy(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo/bar"), options);

        assertThat(getFileSystemEntry("/foo/bar"), instanceOf(DirectoryEntry.class));
        assertSame(foo, getFileSystemEntry("/foo"));
        assertNotSame(baz, getFileSystemEntry("/foo/bar"));
        assertSame(baz, getFileSystemEntry("/baz"));

        DirectoryEntry bar = getDirectory("/foo/bar");
        assertEquals(0, getChildCount("/foo/bar"));
        assertNotEquals(baz.getOwner(), bar.getOwner());
    }

    @Test
    public void testCopyNonEmptyDirDifferentFileSystems() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        DirectoryEntry baz = addDirectory("/baz");
        addFile("/baz/qux");

        baz.setOwner("root");

        CopyOption[] options = {};
        getFileSystem().copy(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo/bar"), options);

        assertThat(getFileSystemEntry("/foo/bar"), instanceOf(DirectoryEntry.class));
        assertSame(foo, getFileSystemEntry("/foo"));
        assertNotSame(baz, getFileSystemEntry("/foo/bar"));
        assertSame(baz, getFileSystemEntry("/baz"));

        DirectoryEntry bar = getDirectory("/foo/bar");
        assertEquals(0, getChildCount("/foo/bar"));
        assertNotEquals(baz.getOwner(), bar.getOwner());
    }

    @Test
    public void testCopyWithAttributes() throws IOException {
        Assertions.assertThrows(UnsupportedOperationException.class, () -> {
            addDirectory("/foo");
            addDirectory("/baz");
            addFile("/baz/qux");

            CopyOption[] options = {StandardCopyOption.COPY_ATTRIBUTES};
            getFileSystem().copy(createPath("/baz"), createPath("/foo/bar"), options);
        });
    }

    @Test
    public void testMoveSame() throws IOException {
        DirectoryEntry foo = addDirectory("/home/test/foo");
        DirectoryEntry bar = addDirectory("/home/test/foo/bar");
        SymbolicLinkEntry baz = addSymLink("/baz", foo);
        CopyOption[] options = {};
        getFileSystem().move(createPath("/"), createPath("/"), options);
        getFileSystem().move(createPath("/home/test"), createPath(""), options);
        getFileSystem().move(createPath("/home/test/foo"), createPath("foo"), options);
        getFileSystem().move(createPath("/home/test/foo/bar"), createPath("foo/bar"), options);
        getFileSystem().move(createPath("/home/test/foo"), createPath("/baz"), options);
        getFileSystem().move(createPath("/baz"), createPath("/home/test/foo"), options);

        assertSame(foo, getFileSystemEntry("/home/test/foo"));
        assertSame(bar, getFileSystemEntry("/home/test/foo/bar"));
        assertSame(baz, getFileSystemEntry("/baz"));
        assertEquals(0, getChildCount("/home/test/foo/bar"));
    }

    @Test
    public void testMoveNonExisting() throws IOException {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            CopyOption[] options = {};
            try {
                getFileSystem().move(createPath("/foo/bar"), createPath("/foo/baz"), options);
            } finally {
                assertSame(foo, getFileSystemEntry("/foo"));
                assertEquals(0, getChildCount("/foo"));
            }
        });
    }

    @Test
    public void testMoveFTPFailure() throws IOException {
        Assertions.assertThrows(FTPFileSystemException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry bar = addFile("/foo/bar");
            CopyOption[] options = {};
            try {
                getFileSystem().move(createPath("/foo/bar"), createPath("/baz/bar"), options);
            } finally {
                verify(getExceptionFactory()).createMoveException(eq("/foo/bar"), eq("/baz/bar"), eq(553), anyString());
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(bar, getFileSystemEntry("/foo/bar"));
                assertNull(getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testMoveEmptyRoot() {
        Assertions.assertThrows(DirectoryNotEmptyException.class, () -> {
            CopyOption[] options = {};
            try {
                getFileSystem().move(createPath("/"), createPath("/baz"), options);
            } finally {
                assertNull(getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testMoveNonEmptyRoot() {
        Assertions.assertThrows(DirectoryNotEmptyException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            CopyOption[] options = {};
            try {
                getFileSystem().move(createPath("/"), createPath("/baz"), options);
            } finally {
                assertSame(foo, getFileSystemEntry("/foo"));
                assertNull(getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testMoveReplaceFile() {
        Assertions.assertThrows(FTPFileSystemException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            DirectoryEntry bar = addDirectory("/foo/bar");
            FileEntry baz = addFile("/baz");

            CopyOption[] options = {};
            try {
                getFileSystem().move(createPath("/baz"), createPath("/foo/bar"), options);
            } finally {
                verify(getExceptionFactory()).createMoveException(eq("/baz"), eq("/foo/bar"), eq(553), anyString());
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(bar, getFileSystemEntry("/foo/bar"));
                assertSame(baz, getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testMoveReplaceFileAllowed() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        addFile("/foo/bar");
        FileEntry baz = addFile("/baz");

        CopyOption[] options = {StandardCopyOption.REPLACE_EXISTING};
        getFileSystem().move(createPath("/baz"), createPath("/foo/bar"), options);

        assertSame(foo, getFileSystemEntry("/foo"));
        assertEqualsMinusPath(baz, getFileSystemEntry("/foo/bar"));
        assertNull(getFileSystemEntry("/baz"));
    }

    @Test
    public void testMoveReplaceEmptyDir() throws IOException {
        Assertions.assertThrows(FTPFileSystemException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry baz = addFile("/baz");

            CopyOption[] options = {};
            try {
                getFileSystem().move(createPath("/baz"), createPath("/foo"), options);
            } finally {
                verify(getExceptionFactory()).createMoveException(eq("/baz"), eq("/foo"), eq(553), anyString());
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(baz, getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testMoveReplaceEmptyDirAllowed() throws IOException {
        addDirectory("/foo");
        FileEntry baz = addFile("/baz");

        CopyOption[] options = {StandardCopyOption.REPLACE_EXISTING};
        getFileSystem().move(createPath("/baz"), createPath("/foo"), options);

        assertEqualsMinusPath(baz, getFileSystemEntry("/foo"));
        assertNull(getFileSystemEntry("/baz"));
    }

    @Test
    public void testMoveFile() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        FileEntry baz = addFile("/baz");

        CopyOption[] options = {};
        getFileSystem().move(createPath("/baz"), createPath("/foo/bar"), options);

        assertSame(foo, getFileSystemEntry("/foo"));
        assertEqualsMinusPath(baz, getFileSystemEntry("/foo/bar"));
        assertNull(getFileSystemEntry("/baz"));
    }

    @Test
    public void testMoveEmptyDir() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        DirectoryEntry baz = addDirectory("/baz");

        CopyOption[] options = {};
        getFileSystem().move(createPath("/baz"), createPath("/foo/bar"), options);

        assertSame(foo, getFileSystemEntry("/foo"));
        assertEqualsMinusPath(baz, getFileSystemEntry("/foo/bar"));
        assertNull(getFileSystemEntry("/baz"));
    }

    @Test
    public void testMoveNonEmptyDir() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        DirectoryEntry baz = addDirectory("/baz");
        FileEntry qux = addFile("/baz/qux");

        CopyOption[] options = {};
        getFileSystem().move(createPath("/baz"), createPath("/foo/bar"), options);

        assertSame(foo, getFileSystemEntry("/foo"));
        assertEqualsMinusPath(baz, getFileSystemEntry("/foo/bar"));
        assertEqualsMinusPath(qux, getFileSystemEntry("/foo/bar/qux"));
        assertEquals(1, getChildCount("/foo"));
        assertEquals(1, getChildCount("/foo/bar"));
    }

    @Test
    public void testMoveNonEmptyDirSameParent() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        FileEntry bar = addFile("/foo/bar");
        CopyOption[] options = {};
        try {
            getFileSystem().move(createPath("/foo"), createPath("/baz"), options);
        } finally {
            assertNull(getFileSystemEntry("/foo"));
            assertEqualsMinusPath(foo, getFileSystemEntry("/baz"));
            assertEqualsMinusPath(bar, getFileSystemEntry("/baz/bar"));
        }
    }

    @Test
    public void testMoveReplaceFileDifferentFileSystems() throws IOException {
        Assertions.assertThrows(FileAlreadyExistsException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            DirectoryEntry bar = addDirectory("/foo/bar");
            FileEntry baz = addFile("/baz");
            CopyOption[] options = {};
            try {
                getFileSystem().move(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo/bar"), options);
            } finally {
                verify(getExceptionFactory(), never()).createMoveException(anyString(), anyString(), anyInt(), anyString());
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(bar, getFileSystemEntry("/foo/bar"));
                assertSame(baz, getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testMoveReplaceFileAllowedDifferentFileSystems() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        addFile("/foo/bar");
        FileEntry baz = addFile("/baz");

        CopyOption[] options = {StandardCopyOption.REPLACE_EXISTING};
        getFileSystem().move(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo/bar"), options);

        assertSame(foo, getFileSystemEntry("/foo"));
        // permissions are dropped during the copy/delete
        assertEqualsMinusPath(baz, getFileSystemEntry("/foo/bar"), false);
        assertNull(getFileSystemEntry("/baz"));
    }

    @Test
    public void testMoveReplaceEmptyDirDifferentFileSystems() {
        Assertions.assertThrows(FileAlreadyExistsException.class, () ->{
            DirectoryEntry foo = addDirectory("/foo");
            FileEntry baz = addFile("/baz");
            CopyOption[] options = {};
            try {
                getFileSystem().move(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo"), options);
            } finally {
                verify(getExceptionFactory(), never()).createMoveException(anyString(), anyString(), anyInt(), anyString());
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(baz, getFileSystemEntry("/baz"));
            }
        });
    }

    @Test
    public void testMoveReplaceEmptyDirAllowedDifferentFileSystems() throws IOException {
        addDirectory("/foo");
        FileEntry baz = addFile("/baz");

        CopyOption[] options = {StandardCopyOption.REPLACE_EXISTING};
        getFileSystem().move(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo"), options);

        // permissions are dropped during the copy/delete
        assertEqualsMinusPath(baz, getFileSystemEntry("/foo"), false);
        assertNull(getFileSystemEntry("/baz"));
    }

    @Test
    public void testMoveFileDifferentFileSystems() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        FileEntry baz = addFile("/baz");

        CopyOption[] options = {};
        getFileSystem().move(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo/bar"), options);

        assertSame(foo, getFileSystemEntry("/foo"));
        // permissions are dropped during the copy/delete
        assertEqualsMinusPath(baz, getFileSystemEntry("/foo/bar"), false);
        assertNull(getFileSystemEntry("/baz"));
    }

    @Test
    public void testMoveEmptyDirDifferentFileSystems() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        DirectoryEntry baz = addDirectory("/baz");

        CopyOption[] options = {};
        getFileSystem().move(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo/bar"), options);

        assertSame(foo, getFileSystemEntry("/foo"));
        // permissions are dropped during the copy/delete
        assertEqualsMinusPath(baz, getFileSystemEntry("/foo/bar"), false);
        assertNull(getFileSystemEntry("/baz"));
    }

    @Test
    public void testMoveNonEmptyDirDifferentFileSystems() {
        Assertions.assertThrows(FTPFileSystemException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            DirectoryEntry baz = addDirectory("/baz");
            addFile("/baz/qux");
            CopyOption[] options = {};
            try {
                getFileSystem().move(createPath("/baz"), createPath(getMultiClientFileSystem(), "/foo/bar"), options);
            } finally {
                verify(getExceptionFactory()).createDeleteException(eq("/baz"), eq(550), anyString(), eq(true));
                assertSame(foo, getFileSystemEntry("/foo"));
                assertSame(baz, getFileSystemEntry("/baz"));
            }
        });
    }

    private void assertEqualsMinusPath(FileSystemEntry entry1, FileSystemEntry entry2) throws IOException {
        assertEqualsMinusPath(entry1, entry2, true);
    }

    private void assertEqualsMinusPath(FileSystemEntry entry1, FileSystemEntry entry2, boolean includePermissions) throws IOException {
        assertEquals(entry1.getClass(), entry2.getClass());
        assertEquals(entry1.getSize(), entry2.getSize());
        assertEquals(entry1.getOwner(), entry2.getOwner());
        assertEquals(entry1.getGroup(), entry2.getGroup());
        if (includePermissions) {
            assertEquals(entry1.getPermissions(), entry2.getPermissions());
        }

        if (entry1 instanceof FileEntry && entry2 instanceof FileEntry) {
            FileEntry file1 = (FileEntry) entry1;
            FileEntry file2 = (FileEntry) entry2;
            assertArrayEquals(getContents(file1), getContents(file2));
        }
    }

    @Test
    public void testIsSameFileEquals() throws IOException {

        assertTrue(getFileSystem().isSameFile(createPath("/"), createPath("/")));
        assertTrue(getFileSystem().isSameFile(createPath("/foo"), createPath("/foo")));
        assertTrue(getFileSystem().isSameFile(createPath("/foo/bar"), createPath("/foo/bar")));

        assertTrue(getFileSystem().isSameFile(createPath(""), createPath("")));
        assertTrue(getFileSystem().isSameFile(createPath("foo"), createPath("foo")));
        assertTrue(getFileSystem().isSameFile(createPath("foo/bar"), createPath("foo/bar")));

        assertTrue(getFileSystem().isSameFile(createPath(""), createPath("/home/test")));
        assertTrue(getFileSystem().isSameFile(createPath("/home/test"), createPath("")));
    }

    @Test
    public void testIsSameFileExisting() throws IOException {
        FileEntry bar = addFile("/home/test/foo/bar");
        addSymLink("/bar", bar);

        assertTrue(getFileSystem().isSameFile(createPath("/home/test"), createPath("")));
        assertTrue(getFileSystem().isSameFile(createPath("/home/test/foo"), createPath("foo")));
        assertTrue(getFileSystem().isSameFile(createPath("/home/test/foo/bar"), createPath("foo/bar")));

        assertTrue(getFileSystem().isSameFile(createPath(""), createPath("/home/test")));
        assertTrue(getFileSystem().isSameFile(createPath("foo"), createPath("/home/test/foo")));
        assertTrue(getFileSystem().isSameFile(createPath("foo/bar"), createPath("/home/test/foo/bar")));

        assertFalse(getFileSystem().isSameFile(createPath("foo"), createPath("foo/bar")));

        assertTrue(getFileSystem().isSameFile(createPath("/bar"), createPath("/home/test/foo/bar")));
    }

    @Test
    public void testIsSameFileFirstNonExisting() {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            getFileSystem().isSameFile(createPath("/foo"), createPath("/"));
        });
    }

    @Test
    public void testIsSameFileSecondNonExisting() {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            getFileSystem().isSameFile(createPath("/"), createPath("/foo"));
        });
    }

    @Test
    public void testIsHidden() throws IOException {
        addDirectory("/foo");
        addDirectory("/.foo");
        addFile("/foo/bar");
        addFile("/foo/.bar");

        assertFalse(getFileSystem().isHidden(createPath("/foo")));
        assertTrue(getFileSystem().isHidden(createPath("/.foo")));
        assertFalse(getFileSystem().isHidden(createPath("/foo/bar")));
        assertTrue(getFileSystem().isHidden(createPath("/foo/.bar")));
    }

    @Test
    public void testIsHiddenNonExisting() throws IOException {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            getFileSystem().isHidden(createPath("/foo"));
        });
    }

    @Test
    public void testCheckAccessNonExisting() {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            getFileSystem().checkAccess(createPath("/foo/bar"));
        });
    }

    @Test
    public void testCheckAccessNoModes() throws IOException {
        addDirectory("/foo/bar");
        getFileSystem().checkAccess(createPath("/foo/bar"));
    }

    @Test
    public void testCheckAccessOnlyRead() throws IOException {
        addDirectory("/foo/bar");
        getFileSystem().checkAccess(createPath("/foo/bar"), AccessMode.READ);
    }

    @Test
    public void testCheckAccessOnlyWriteNotReadOnly() throws IOException {
        addDirectory("/foo/bar");
        getFileSystem().checkAccess(createPath("/foo/bar"), AccessMode.WRITE);
    }

    @Test
    public void testCheckAccessOnlyWriteReadOnly() {
        Assertions.assertThrows(AccessDeniedException.class, () -> {
            DirectoryEntry bar = addDirectory("/foo/bar");
            bar.setPermissionsFromString("r-xr-xr-x");
            getFileSystem().checkAccess(createPath("/foo/bar"), AccessMode.WRITE);
        });
    }

    @Test
    public void testCheckAccessOnlyExecute() {
        Assertions.assertThrows(AccessDeniedException.class, () -> {
            DirectoryEntry bar = addDirectory("/foo/bar");
            bar.setPermissionsFromString("rw-rw-rw-");
            getFileSystem().checkAccess(createPath("/foo/bar"), AccessMode.EXECUTE);
        });
    }

    @Test
    public void testReadAttributesFileFollowLinks() throws IOException {
        FileEntry foo = addFile("/foo");
        foo.setContents(new byte[1024]);
        foo.setPermissionsFromString("r-xr-xr-x");
        foo.setOwner("user");
        foo.setGroup("group");

        PosixFileAttributes attributes = getFileSystem().readAttributes(createPath("/foo"));

        assertEquals(foo.getSize(), attributes.size());
        assertEquals("user", attributes.owner().getName());
        assertEquals("group", attributes.group().getName());
        assertEquals(PosixFilePermissions.fromString("r-xr-xr-x"), attributes.permissions());
        assertFalse(attributes.isDirectory());
        assertTrue(attributes.isRegularFile());
        assertFalse(attributes.isSymbolicLink());
        assertFalse(attributes.isOther());
    }

    @Test
    public void testReadAttributesFileNoFollowLinks() throws IOException {
        FileEntry foo = addFile("/foo");
        foo.setContents(new byte[1024]);
        foo.setPermissionsFromString("r-xr-xr-x");
        foo.setOwner("user");
        foo.setGroup("group");

        PosixFileAttributes attributes = getFileSystem().readAttributes(createPath("/foo"), LinkOption.NOFOLLOW_LINKS);

        assertEquals(foo.getSize(), attributes.size());
        assertEquals("user", attributes.owner().getName());
        assertEquals("group", attributes.group().getName());
        assertEquals(PosixFilePermissions.fromString("r-xr-xr-x"), attributes.permissions());
        assertFalse(attributes.isDirectory());
        assertTrue(attributes.isRegularFile());
        assertFalse(attributes.isSymbolicLink());
        assertFalse(attributes.isOther());
    }

    @Test
    public void testReadAttributesDirectoryFollowLinks() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        foo.setPermissionsFromString("r-xr-xr-x");
        foo.setOwner("user");
        foo.setGroup("group");

        PosixFileAttributes attributes = getFileSystem().readAttributes(createPath("/foo"));

        assertEquals(foo.getSize(), attributes.size());
        assertEquals("user", attributes.owner().getName());
        assertEquals("group", attributes.group().getName());
        assertEquals(PosixFilePermissions.fromString("r-xr-xr-x"), attributes.permissions());
        assertTrue(attributes.isDirectory());
        assertFalse(attributes.isRegularFile());
        assertFalse(attributes.isSymbolicLink());
        assertFalse(attributes.isOther());
    }

    @Test
    public void testReadAttributesDirectoryNoFollowLinks() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        foo.setPermissionsFromString("r-xr-xr-x");
        foo.setOwner("user");
        foo.setGroup("group");

        PosixFileAttributes attributes = getFileSystem().readAttributes(createPath("/foo"), LinkOption.NOFOLLOW_LINKS);

        assertEquals(foo.getSize(), attributes.size());
        assertEquals("user", attributes.owner().getName());
        assertEquals("group", attributes.group().getName());
        assertEquals(PosixFilePermissions.fromString("r-xr-xr-x"), attributes.permissions());
        assertTrue(attributes.isDirectory());
        assertFalse(attributes.isRegularFile());
        assertFalse(attributes.isSymbolicLink());
        assertFalse(attributes.isOther());
    }

    @Test
    public void testReadAttributesSymLinkToFileFollowLinks() throws IOException {
        FileEntry foo = addFile("/foo");
        foo.setContents(new byte[1024]);
        foo.setPermissionsFromString("r-xr-xr-x");
        foo.setOwner("user");
        foo.setGroup("group");
        SymbolicLinkEntry bar = addSymLink("/bar", foo);

        PosixFileAttributes attributes = getFileSystem().readAttributes(createPath("/bar"));

        assertEquals(foo.getSize(), attributes.size());
        assertNotEquals(bar.getSize(), attributes.size());
        assertEquals("user", attributes.owner().getName());
        assertEquals("group", attributes.group().getName());
        assertEquals(PosixFilePermissions.fromString("r-xr-xr-x"), attributes.permissions());
        assertFalse(attributes.isDirectory());
        assertTrue(attributes.isRegularFile());
        assertFalse(attributes.isSymbolicLink());
        assertFalse(attributes.isOther());
    }

    @Test
    public void testReadAttributesSymLinkToFileNoFollowLinks() throws IOException {
        FileEntry foo = addFile("/foo");
        foo.setPermissionsFromString("r-xr-xr-x");
        foo.setOwner("user");
        foo.setGroup("group");
        SymbolicLinkEntry bar = addSymLink("/bar", foo);

        PosixFileAttributes attributes = getFileSystem().readAttributes(createPath("/bar"), LinkOption.NOFOLLOW_LINKS);

        assertEquals(bar.getSize(), attributes.size());
        assertNotEquals(foo.getSize(), attributes.size());
        assertEquals("user", attributes.owner().getName());
        assertEquals("group", attributes.group().getName());
        assertEquals(PosixFilePermissions.fromString("rwxrwxrwx"), attributes.permissions());
        assertFalse(attributes.isDirectory());
        assertFalse(attributes.isRegularFile());
        assertTrue(attributes.isSymbolicLink());
        assertFalse(attributes.isOther());
    }

    @Test
    public void testReadAttributesSymLinkToDirectoryFollowLinks() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        foo.setPermissionsFromString("r-xr-xr-x");
        foo.setOwner("user");
        foo.setGroup("group");
        SymbolicLinkEntry bar = addSymLink("/bar", foo);

        PosixFileAttributes attributes = getFileSystem().readAttributes(createPath("/bar"));

        assertEquals(foo.getSize(), attributes.size());
        assertNotEquals(bar.getSize(), attributes.size());
        assertEquals("user", attributes.owner().getName());
        assertEquals("group", attributes.group().getName());
        assertEquals(PosixFilePermissions.fromString("r-xr-xr-x"), attributes.permissions());
        assertTrue(attributes.isDirectory());
        assertFalse(attributes.isRegularFile());
        assertFalse(attributes.isSymbolicLink());
        assertFalse(attributes.isOther());
    }

    @Test
    public void testReadAttributesSymLinkToDirectoryNoFollowLinks() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        foo.setPermissionsFromString("r-xr-xr-x");
        foo.setOwner("user");
        foo.setGroup("group");
        SymbolicLinkEntry bar = addSymLink("/bar", foo);

        PosixFileAttributes attributes = getFileSystem().readAttributes(createPath("/bar"), LinkOption.NOFOLLOW_LINKS);

        assertEquals(bar.getSize(), attributes.size());
        assertNotEquals(foo.getSize(), attributes.size());
        assertEquals("user", attributes.owner().getName());
        assertEquals("group", attributes.group().getName());
        assertEquals(PosixFilePermissions.fromString("rwxrwxrwx"), attributes.permissions());
        assertFalse(attributes.isDirectory());
        assertFalse(attributes.isRegularFile());
        assertTrue(attributes.isSymbolicLink());
        assertFalse(attributes.isOther());
    }

    @Test
    public void testReadAttributesNonExisting() {
        Assertions.assertThrows(NoSuchFileException.class, () ->
                getFileSystem().readAttributes(createPath("/foo"))
        );
    }

    @Test
    public void testReadAttributesMapNoTypeLastModifiedTime() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "lastModifiedTime");
        assertEquals(Collections.singleton("basic:lastModifiedTime"), attributes.keySet());
        assertNotNull(attributes.get("basic:lastModifiedTime"));
    }

    @Test
    public void testReadAttributesMapNoTypeLastAccessTime() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "lastAccessTime");
        assertEquals(Collections.singleton("basic:lastAccessTime"), attributes.keySet());
        assertNotNull(attributes.get("basic:lastAccessTime"));
    }

    @Test
    public void testReadAttributesMapNoTypeCreateTime() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "creationTime");
        assertEquals(Collections.singleton("basic:creationTime"), attributes.keySet());
        assertNotNull(attributes.get("basic:creationTime"));
    }

    @Test
    public void testReadAttributesMapNoTypeBasicSize() throws IOException {
        FileEntry foo = addFile("/foo");
        foo.setContents(new byte[1024]);

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "size");
        Map<String, ?> expected = Collections.singletonMap("basic:size", foo.getSize());
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapNoTypeIsRegularFile() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "isRegularFile");
        Map<String, ?> expected = Collections.singletonMap("basic:isRegularFile", false);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapNoTypeIsDirectory() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "isDirectory");
        Map<String, ?> expected = Collections.singletonMap("basic:isDirectory", true);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapNoTypeIsSymbolicLink() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "isSymbolicLink");
        Map<String, ?> expected = Collections.singletonMap("basic:isSymbolicLink", false);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapNoTypeIsOther() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "isOther");
        Map<String, ?> expected = Collections.singletonMap("basic:isOther", false);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapNoTypeFileKey() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "fileKey");
        Map<String, ?> expected = Collections.singletonMap("basic:fileKey", null);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapNoTypeMultiple() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "size,isDirectory");
        Map<String, Object> expected = new HashMap<>();
        expected.put("basic:size", foo.getSize());
        expected.put("basic:isDirectory", true);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapNoTypeAll() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "*");
        Map<String, Object> expected = new HashMap<>();
        expected.put("basic:size", foo.getSize());
        expected.put("basic:isRegularFile", false);
        expected.put("basic:isDirectory", true);
        expected.put("basic:isSymbolicLink", false);
        expected.put("basic:isOther", false);
        expected.put("basic:fileKey", null);

        assertNotNull(attributes.remove("basic:lastModifiedTime"));
        assertNotNull(attributes.remove("basic:lastAccessTime"));
        assertNotNull(attributes.remove("basic:creationTime"));
        assertEquals(expected, attributes);

        attributes = getFileSystem().readAttributes(createPath("/foo"), "basic:lastModifiedTime,*");
        assertNotNull(attributes.remove("basic:lastModifiedTime"));
        assertNotNull(attributes.remove("basic:lastAccessTime"));
        assertNotNull(attributes.remove("basic:creationTime"));
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapBasicLastModifiedTime() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "basic:lastModifiedTime");
        assertEquals(Collections.singleton("basic:lastModifiedTime"), attributes.keySet());
        assertNotNull(attributes.get("basic:lastModifiedTime"));
    }

    @Test
    public void testReadAttributesMapBasicLastAccessTime() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "basic:lastAccessTime");
        assertEquals(Collections.singleton("basic:lastAccessTime"), attributes.keySet());
        assertNotNull(attributes.get("basic:lastAccessTime"));
    }

    @Test
    public void testReadAttributesMapBasicCreateTime() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "basic:creationTime");
        assertEquals(Collections.singleton("basic:creationTime"), attributes.keySet());
        assertNotNull(attributes.get("basic:creationTime"));
    }

    @Test
    public void testReadAttributesMapBasicSize() throws IOException {
        FileEntry foo = addFile("/foo");
        foo.setContents(new byte[1024]);

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "basic:size");
        Map<String, ?> expected = Collections.singletonMap("basic:size", foo.getSize());
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapBasicIsRegularFile() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "basic:isRegularFile");
        Map<String, ?> expected = Collections.singletonMap("basic:isRegularFile", false);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapBasicIsDirectory() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "basic:isDirectory");
        Map<String, ?> expected = Collections.singletonMap("basic:isDirectory", true);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapBasicIsSymbolicLink() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "basic:isSymbolicLink");
        Map<String, ?> expected = Collections.singletonMap("basic:isSymbolicLink", false);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapBasicIsOther() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "basic:isOther");
        Map<String, ?> expected = Collections.singletonMap("basic:isOther", false);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapBasicFileKey() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "basic:fileKey");
        Map<String, ?> expected = Collections.singletonMap("basic:fileKey", null);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapBasicMultiple() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "basic:size,isDirectory");
        Map<String, Object> expected = new HashMap<>();
        expected.put("basic:size", foo.getSize());
        expected.put("basic:isDirectory", true);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapBasicAll() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "basic:*");
        Map<String, Object> expected = new HashMap<>();
        expected.put("basic:size", foo.getSize());
        expected.put("basic:isRegularFile", false);
        expected.put("basic:isDirectory", true);
        expected.put("basic:isSymbolicLink", false);
        expected.put("basic:isOther", false);
        expected.put("basic:fileKey", null);

        assertNotNull(attributes.remove("basic:lastModifiedTime"));
        assertNotNull(attributes.remove("basic:lastAccessTime"));
        assertNotNull(attributes.remove("basic:creationTime"));
        assertEquals(expected, attributes);

        attributes = getFileSystem().readAttributes(createPath("/foo"), "basic:lastModifiedTime,*");
        assertNotNull(attributes.remove("basic:lastModifiedTime"));
        assertNotNull(attributes.remove("basic:lastAccessTime"));
        assertNotNull(attributes.remove("basic:creationTime"));
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapPosixLastModifiedTime() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:lastModifiedTime");
        assertEquals(Collections.singleton("posix:lastModifiedTime"), attributes.keySet());
        assertNotNull(attributes.get("posix:lastModifiedTime"));
    }

    @Test
    public void testReadAttributesMapPosixLastAccessTime() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:lastAccessTime");
        assertEquals(Collections.singleton("posix:lastAccessTime"), attributes.keySet());
        assertNotNull(attributes.get("posix:lastAccessTime"));
    }

    @Test
    public void testReadAttributesMapPosixCreateTime() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:creationTime");
        assertEquals(Collections.singleton("posix:creationTime"), attributes.keySet());
        assertNotNull(attributes.get("posix:creationTime"));
    }

    @Test
    public void testReadAttributesMapPosixSize() throws IOException {
        FileEntry foo = addFile("/foo");
        foo.setContents(new byte[1024]);

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:size");
        Map<String, ?> expected = Collections.singletonMap("posix:size", foo.getSize());
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapPosixIsRegularFile() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:isRegularFile");
        Map<String, ?> expected = Collections.singletonMap("posix:isRegularFile", false);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapPosixIsDirectory() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:isDirectory");
        Map<String, ?> expected = Collections.singletonMap("posix:isDirectory", true);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapPosixIsSymbolicLink() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:isSymbolicLink");
        Map<String, ?> expected = Collections.singletonMap("posix:isSymbolicLink", false);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapPosixIsOther() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:isOther");
        Map<String, ?> expected = Collections.singletonMap("posix:isOther", false);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapPosixFileKey() throws IOException {
        addDirectory("/foo");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:fileKey");
        Map<String, ?> expected = Collections.singletonMap("posix:fileKey", null);
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapOwnerOwner() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        foo.setOwner("test");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "owner:owner");
        Map<String, ?> expected = Collections.singletonMap("owner:owner", new SimpleUserPrincipal(foo.getOwner()));
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapOwnerAll() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        foo.setOwner("test");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "owner:*");
        Map<String, Object> expected = new HashMap<>();
        expected.put("owner:owner", new SimpleUserPrincipal(foo.getOwner()));
        assertEquals(expected, attributes);

        attributes = getFileSystem().readAttributes(createPath("/foo"), "owner:owner,*");
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapPosixOwner() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        foo.setOwner("test");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:owner");
        Map<String, ?> expected = Collections.singletonMap("posix:owner", new SimpleUserPrincipal(foo.getOwner()));
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapPosixGroup() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        foo.setGroup("test");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:group");
        Map<String, ?> expected = Collections.singletonMap("posix:group", new SimpleGroupPrincipal(foo.getGroup()));
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapPosixPermissions() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        foo.setPermissionsFromString("r-xr-xr-x");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:permissions");
        Map<String, ?> expected = Collections.singletonMap("posix:permissions", PosixFilePermissions.fromString(foo.getPermissions().asRwxString()));
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapPosixMultiple() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        foo.setOwner("test");
        foo.setGroup("test");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:size,owner,group");
        Map<String, Object> expected = new HashMap<>();
        expected.put("posix:size", foo.getSize());
        expected.put("posix:owner", new SimpleUserPrincipal(foo.getOwner()));
        expected.put("posix:group", new SimpleGroupPrincipal(foo.getGroup()));
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapPosixAll() throws IOException {
        DirectoryEntry foo = addDirectory("/foo");
        foo.setOwner("test");
        foo.setGroup("group");
        foo.setPermissionsFromString("r-xr-xr-x");

        Map<String, Object> attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:*");
        Map<String, Object> expected = new HashMap<>();
        expected.put("posix:size", foo.getSize());
        expected.put("posix:isRegularFile", false);
        expected.put("posix:isDirectory", true);
        expected.put("posix:isSymbolicLink", false);
        expected.put("posix:isOther", false);
        expected.put("posix:fileKey", null);
        expected.put("posix:owner", new SimpleUserPrincipal(foo.getOwner()));
        expected.put("posix:group", new SimpleGroupPrincipal(foo.getGroup()));
        expected.put("posix:permissions", PosixFilePermissions.fromString(foo.getPermissions().asRwxString()));

        assertNotNull(attributes.remove("posix:lastModifiedTime"));
        assertNotNull(attributes.remove("posix:lastAccessTime"));
        assertNotNull(attributes.remove("posix:creationTime"));
        assertEquals(expected, attributes);

        attributes = getFileSystem().readAttributes(createPath("/foo"), "posix:lastModifiedTime,*");
        assertNotNull(attributes.remove("posix:lastModifiedTime"));
        assertNotNull(attributes.remove("posix:lastAccessTime"));
        assertNotNull(attributes.remove("posix:creationTime"));
        assertEquals(expected, attributes);
    }

    @Test
    public void testReadAttributesMapUnsupportedAttribute() throws IOException {
        Assertions.assertThrows(IllegalArgumentException.class, () -> {
            DirectoryEntry foo = addDirectory("/foo");
            foo.setOwner("test");
            getFileSystem().readAttributes(createPath("/foo"), "posix:lastModifiedTime,owner,dummy");
        });
    }

    @Test
    public void testReadAttributesMapUnsupportedType() throws IOException {
        Assertions.assertThrows(UnsupportedOperationException.class, () -> {
            addDirectory("/foo");
            getFileSystem().readAttributes(createPath("/foo"), "zipfs:*");
        });
    }

    @Test
    public void testGetFTPFileFile() throws IOException {
        addFile("/foo");

        FTPFile file = getFileSystem().getFTPFile(createPath("/foo"));
        assertNotNull(file);
        assertEquals("foo", file.getName());
        assertTrue(file.isFile());
    }

    @Test
    public void testGetFTPFileFileNotExisting() {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            try {
                getFileSystem().getFTPFile(createPath("/foo"));

            } finally {
                VerificationMode verificationMode = times(1);
                verify(getExceptionFactory(), verificationMode).createGetFileException(eq("/foo"), eq(226), anyString());
            }
        });
    }

    @Test
    public void testGetFTPFileFileAccessDenied() {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            addFile("/foo/bar");
            getFile("/foo/bar").setPermissionsFromString("---------");
            try {
                FTPFile file = getFileSystem().getFTPFile(createPath("/foo/bar"));
                assertNotNull(file);
                assertEquals("bar", file.getName());
                assertTrue(file.isFile());
                for (int access = FTPFile.USER_ACCESS; access <= FTPFile.WORLD_ACCESS; access++) {
                    for (int permission = FTPFile.READ_PERMISSION; permission <= FTPFile.EXECUTE_PERMISSION; permission++) {
                        assertFalse(file.hasPermission(access, permission));
                    }
                }
            } finally {
                VerificationMode verificationMode = times(1);
                verify(getExceptionFactory(), verificationMode).createGetFileException(eq("/foo/bar"), eq(550), anyString());
            }
        });
    }

    @Test
    public void testGetFTPFileDirectory() throws IOException {
        addDirectory("/foo");
        FTPFile file = getFileSystem().getFTPFile(createPath("/foo"));
        assertNotNull(file);
        assertEquals(".", file.getName());
        assertTrue(file.isDirectory());
    }

    @Test
    public void testGetFTPFileDirectoryAccessDenied() {
        Assertions.assertThrows(NoSuchFileException.class, () -> {
            DirectoryEntry bar = addDirectory("/foo/bar");
            bar.setPermissionsFromString("---------");
            try {
                FTPFile file = getFileSystem().getFTPFile(createPath("/foo/bar"));
                assertNotNull(file);
                assertEquals("bar", file.getName());
                assertTrue(file.isDirectory());
                for (int access = FTPFile.USER_ACCESS; access <= FTPFile.WORLD_ACCESS; access++) {
                    for (int permission = FTPFile.READ_PERMISSION; permission <= FTPFile.EXECUTE_PERMISSION; permission++) {
                        assertFalse(file.hasPermission(access, permission));
                    }
                }
            } finally {
                VerificationMode verificationMode = times(1);
                verify(getExceptionFactory(), verificationMode).createGetFileException(eq("/foo/bar"), eq(550), anyString());
            }
        });
    }

    private static final class AcceptAllFilter implements Filter<Path> {

        private static final AcceptAllFilter INSTANCE = new AcceptAllFilter();

        @Override
        public boolean accept(Path entry) {
            return true;
        }
    }
}
