diff --git a/DataAccess/Extensions/ZipExtensionWrapper.cs b/DataAccess/Extensions/ZipExtensionWrapper.cs new file mode 100644 index 00000000..081a7b7e --- /dev/null +++ b/DataAccess/Extensions/ZipExtensionWrapper.cs @@ -0,0 +1,36 @@ +using System.IO.Abstractions; +using System.IO.Compression; + +namespace DataAccess.Extensions; +/// +public class ZipExtensionWrapper +{ + /// + /// Gets the file system used by this wrapper. + /// + public IFileSystem FileSystem { get; private set; } + + + public ZipExtensionWrapper(IFileSystem fileSystem) + { + FileSystem = fileSystem; + } + + /// + public ZipArchive GetZipArchive(string archivePath) + { + return ZipExtensions.GetZipArchive(FileSystem, archivePath); + } + + /// + public void ExtractToDirectory(ZipArchive archive, string destination) + { + ZipExtensions.ExtractToDirectory(archive, FileSystem, destination); + } + + /// + public async Task CreateFromDirectoryAsync(string sourcePath, string destinationPath) + { + await ZipExtensions.CreateFromDirectoryAsync(FileSystem, sourcePath, destinationPath); + } +} \ No newline at end of file diff --git a/DataAccess/Extensions/ZipExtensions.cs b/DataAccess/Extensions/ZipExtensions.cs index 38576df8..38d9ec40 100644 --- a/DataAccess/Extensions/ZipExtensions.cs +++ b/DataAccess/Extensions/ZipExtensions.cs @@ -6,74 +6,126 @@ namespace DataAccess.Extensions; public static class ZipExtensions { /// - /// Creates a object for a given path on the filesystem . + /// Opens a zip archive in read mode. /// - /// The filesystem on which the file resides. - /// The path to the zip archive on the filesystem. - /// - public static ZipArchive GetZipArchive(IFileSystem fs, string archivePath) + /// The file system abstraction used to access the file. + /// The path to the zip archive file. + /// A instance representing the archive. + /// Thrown if or is null. + /// Thrown if the specified file does not exist. + /// Thrown if the caller does not have the required permissions. + /// Thrown if the file is not a valid zip archive. + public static ZipArchive GetZipArchive(IFileSystem fileSystemReference, string archivePath) { - var zipStream = fs.File.OpenRead(archivePath); + var zipStream = fileSystemReference.File.OpenRead(archivePath); return new ZipArchive(zipStream, ZipArchiveMode.Read); } - - private static ZipArchive GetWritableZipArchive(IFileSystem fs, string archivePath) + + private static ZipArchive GetWritableZipArchive(IFileSystem fileSystemReference, string archivePath) { - var zipStream = fs.File.OpenWrite(archivePath); + var zipStream = fileSystemReference.File.OpenWrite(archivePath); return new ZipArchive(zipStream, ZipArchiveMode.Create); } - /// - /// Extracts the contents of a to a given directory on the filesystem . + /// Extracts all entries from a zip archive to the specified directory. /// /// The zip archive to extract. - /// The filesystem to extract the archive onto. - /// The destination path on the filesystem. - public static void ExtractToDirectory(this ZipArchive archive, IFileSystem fs, string destination) + /// The file system abstraction used to access files and directories. + /// The directory to extract the archive's contents to. + /// Thrown if is null. + /// Thrown if the caller does not have write access to the destination directory. + /// Thrown if an I/O error occurs during extraction. + public static void ExtractToDirectory(this ZipArchive archive, IFileSystem fileSystemReference, string destination) { - if (!fs.Directory.Exists(destination)) - { - fs.Directory.CreateDirectory(destination); - } + EnsureDestinationDirectoryExists(fileSystemReference, destination); foreach (var entry in archive.Entries) { - var entryFullName = Path.DirectorySeparatorChar switch - { - //adjust paths for unpacking on unix when packed on windows - '/' => entry.FullName.Replace("\\", "/"), - //adjust paths for unpacking on windows when packed on unix - '\\' => entry.FullName.Replace("/", "\\"), - _ => entry.FullName - }; - var path = Path.Combine(destination, entryFullName); - var directoryName = Path.GetDirectoryName(path); - if (directoryName != null) fs.Directory.CreateDirectory(directoryName); - if (fs.File.Exists(path)) fs.File.Delete(path); + var extractedEntryPath = NormalizeExtractedEntryPathForWindowsAndUnix(entry); + var fullPath = Path.Combine(destination, extractedEntryPath); + CreateEntryDirectory(fileSystemReference, fullPath); + + if (fileSystemReference.File.Exists(fullPath)) + fileSystemReference.File.Delete(fullPath); - using var destStream = fs.File.Create(path); + using var destinationStream = fileSystemReference.File.Create(fullPath); using var sourceStream = entry.Open(); - sourceStream.CopyTo(destStream); + sourceStream.CopyTo(destinationStream); } } - /// - /// Creates a zip archive from a given directory on the filesystem . + /// Creates a zip archive from the contents of a directory asynchronously. /// - /// The filesystem to operate on. - /// The folder that should be packed into the zip archive. - /// The file path the zip archive should be written to. - public static async Task CreateFromDirectoryAsync(IFileSystem fs, string source, string destination) + /// The file system abstraction used to access files and directories. + /// The source directory to compress. + /// The path of the resulting zip archive. + /// A task that represents the asynchronous operation. + /// Thrown if or is null. + /// Thrown if the source directory does not exist. + /// Thrown if the caller does not have the required permissions. + /// Thrown if an I/O error occurs during compression. + public static async Task CreateFromDirectoryAsync(IFileSystem fileSystemReference, string source, string destination) { - using var archive = GetWritableZipArchive(fs, destination); - var files = fs.Directory.GetFiles(source, "*", SearchOption.AllDirectories); + ValidatePath(source); + ValidatePath(destination); + using var archive = GetWritableZipArchive(fileSystemReference, destination); + + var files = GetAllFilesExceptSymbolicLinksToPreventLoops(fileSystemReference, source); foreach (var file in files) { var relativePath = Path.GetRelativePath(source, file); - var entry = archive.CreateEntry(relativePath); - await using var entryStream = entry.Open(); - await using var fileStream = fs.File.OpenRead(file); - await fileStream.CopyToAsync(entryStream); + await AddFileToArchiveAsync(fileSystemReference, archive, relativePath, file); + } + } + private static void ValidatePath(string path) + { + if (path == null) + throw new ArgumentNullException(nameof(path), "Path cannot be null."); + if (path.IndexOfAny(Path.GetInvalidPathChars()) >= 0) + { + throw new ArgumentException($"The path is invalid: {path}"); + } + } + private static async Task AddFileToArchiveAsync(IFileSystem fileSystemReference, ZipArchive archive, + string relativePath, string file) + { + var entry = archive.CreateEntry(relativePath); + await using var entryStream = entry.Open(); + await using var fileStream = fileSystemReference.File.OpenRead(file); + await fileStream.CopyToAsync(entryStream); + } + + private static string[] GetAllFilesExceptSymbolicLinksToPreventLoops(IFileSystem fileSystemReference, string source) + { + return fileSystemReference.Directory.GetFiles(source, "*", SearchOption.AllDirectories) + .Where(file => !fileSystemReference.File.GetAttributes(file).HasFlag(FileAttributes.ReparsePoint)) + .ToArray(); + } + + + private static void CreateEntryDirectory(IFileSystem fileSystemReference, string fullPath) + { + var directoryName = Path.GetDirectoryName(fullPath); + if (directoryName != null) fileSystemReference.Directory.CreateDirectory(directoryName); + } + + private static string NormalizeExtractedEntryPathForWindowsAndUnix(ZipArchiveEntry entry) + { + return Path.DirectorySeparatorChar switch + { + //adjust paths for unpacking on unix when packed on windows + '/' => entry.FullName.Replace("\\", "/"), + //adjust paths for unpacking on windows when packed on unix + '\\' => entry.FullName.Replace("/", "\\"), + _ => entry.FullName + }; + } + + private static void EnsureDestinationDirectoryExists(IFileSystem fileSystemReference, string destination) + { + if (!fileSystemReference.Directory.Exists(destination)) + { + fileSystemReference.Directory.CreateDirectory(destination); } } } \ No newline at end of file diff --git a/DataAccessTest/Extensions/ZipExtensionWrapperUt.cs b/DataAccessTest/Extensions/ZipExtensionWrapperUt.cs new file mode 100644 index 00000000..e8965002 --- /dev/null +++ b/DataAccessTest/Extensions/ZipExtensionWrapperUt.cs @@ -0,0 +1,644 @@ +using DataAccess.Extensions; +using NUnit.Framework; + +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; +using System.IO.Compression; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace DataAccessTest.Extensions; +[TestFixture] +public class ZipExtensionUnitTest +{ + private MockFileSystem _mockFileSystem; + + [SetUp] + public void SetUp() + { + _mockFileSystem = new MockFileSystem(); + } + public ZipExtensionWrapper CreateZipExtensionWrapper(IFileSystem? fileSystem = null) + { + fileSystem ??= new MockFileSystem(); + return new ZipExtensionWrapper(fileSystem); + } + + [Test] + public void GetZipArchive_ValidArchive_ReturnsZipArchive() + { + var archivePath = "myZipFile.zip"; + var memoryStream = CreateMemmoryStreamWithZipArchive(); + _mockFileSystem.AddFile(archivePath, new MockFileData(memoryStream.ToArray())); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + var result = systemUnderTest.GetZipArchive(archivePath); + + Assert.That(result, Is.TypeOf()); + } + [Test] + public void GetZipArchive_NonExistentFile_ThrowsFileNotFoundException() + { + var nonExistentFilePath = @"C:\test\nonexistent.zip"; + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + + Assert.Throws(() => systemUnderTest.GetZipArchive(nonExistentFilePath)); + } + [Test] + public void GetZipArchive_NotAZipFile_ThrowsInvalidDataException() + { + var invalidZipFilePath = @"C:\test\invalid.zip"; + _mockFileSystem.AddFile(invalidZipFilePath, new MockFileData("Not a zip file")); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + + Assert.Throws(() => systemUnderTest.GetZipArchive( invalidZipFilePath)); + } + [Test] + public void GetZipArchive_NullArgument_ThrowsArgumentNullException() + { + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + + Assert.Throws(() => systemUnderTest.GetZipArchive( null)); + } + + [Test] + public void GetZipArchive_InvalidCharactersInPath_ThrowsArgumentException([Range(0, 32)] int number) + { + var badChars = Path.GetInvalidPathChars();// 33 elements + const string validPath = @"C:\Temp\Invalid"; + var invalidPath = validPath + badChars[number]; + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + Assert.Throws(() => systemUnderTest.GetZipArchive( invalidPath)); + } + [Test] + public void GetZipArchive_InvalidPathFormat_ThrowsNotSupportedException() + { + var invalidFormatPath = "://InvalidPath/file.zip"; + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + + Assert.Throws(() => systemUnderTest.GetZipArchive( invalidFormatPath)); + } + /// + /// Implementing tested with OpenRead. It would be better if a mock file would be constructed using the TestingHelpers that we are not allowed to access. + /// + [Test] + public void GetZipArchiveAccessIsDenied_ThrowsUnauthorizedAccessException() + { + var fileSystemMock = NSubstitute.Substitute.For(); + string archivePath = "test.zip"; + fileSystemMock.File + .OpenRead(Arg.Any()) + .Returns( _=> throw new UnauthorizedAccessException("Simulated Unauthorized Access")); + + var systemUnderTest = CreateZipExtensionWrapper(fileSystemMock); + + Assert.Throws(() => + { + systemUnderTest.GetZipArchive(archivePath); + }); + } + /// + /// Implementing tested with OpenRead. It would be better if a mock file would be constructed using the TestingHelpers that we are not allowed to access. + /// + [Test] + public void GetZipArchiveFileCannotBeRead_ThrowsIOException() + { + var fileSystemMock = NSubstitute.Substitute.For(); + string archivePath = "test.zip"; + fileSystemMock.File + .OpenRead(Arg.Any()) + .Returns( _=> throw new IOException("Simulated IO Exception")); + + var systemUnderTest = CreateZipExtensionWrapper(fileSystemMock); + + Assert.Throws(() => + { + systemUnderTest.GetZipArchive(archivePath); + }); + } + [Test] + public void GetZipArchive_LargeArchive_Success() + { + var archivePath = "largeArchive.zip"; + var memoryStream = CreateLargeArchive(); + _mockFileSystem.AddFile(archivePath, new MockFileData(memoryStream.ToArray())); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + var result = systemUnderTest.GetZipArchive(archivePath); + + Assert.That(result, Is.TypeOf()); + } + [Test] + public void GetZipArchive_WhitespacePath_ThrowsArgumentException() + { + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + + Assert.Throws(() => systemUnderTest.GetZipArchive(" ")); + } + /// + /// Implementing tested with OpenRead. TestingHelper doesn't implement PathTooLong exception. + /// + [Test] + public void GetZipArchive_PathTooLong_ThrowsPathTooLongException() + { + var longPath = new string('a', 260) + ".zip"; + var mockFileSystem = Substitute.For(); + mockFileSystem.File.OpenRead(Arg.Is(path => path.Length > 259)) + .Throws(); + + var systemUnderTest = CreateZipExtensionWrapper(mockFileSystem); + + Assert.Throws(() => systemUnderTest.GetZipArchive(longPath)); + } + private static MemoryStream CreateLargeArchive() + { + var memoryStream = new MemoryStream(); + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) + { + for (int i = 0; i < 1000; i++) + { + var entry = archive.CreateEntry($"file{i}.txt"); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream); + writer.Write($"This is file {i}"); + } + } + + memoryStream.Seek(0, SeekOrigin.Begin); + return memoryStream; + } + + private static MemoryStream CreateMemmoryStreamWithZipArchive() + { + MemoryStream? memoryStream = null; + try + { + memoryStream = new MemoryStream(); + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) + { + var demoFile = archive.CreateEntry("test.txt"); + using (var entryStream = demoFile.Open()) + using (var streamWriter = new StreamWriter(entryStream)) + { + streamWriter.Write("Hello, world!"); + } + } + + return memoryStream; + } + catch + { + memoryStream?.Dispose(); + throw; + } + } + [Test] + public void ExtractToDirectory_ValidArchiveAndDestination_ExtractsFilesSuccessfully() + { + var mockFile = new MockFileData("Mock zip content"); + _mockFileSystem.AddFile("/mockArchive.zip", mockFile); + var mockArchive = GetMockZipArchive(new[] { "file1.txt", "folder/file2.txt" }); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + systemUnderTest.ExtractToDirectory(mockArchive, "/extracted"); + + Assert.That(_mockFileSystem.Directory.Exists("/extracted"), Is.True); + Assert.That(_mockFileSystem.File.Exists("/extracted/file1.txt"), Is.True); + Assert.That(_mockFileSystem.File.Exists("/extracted/folder/file2.txt"), Is.True); + } + + [Test] + public void ExtractToDirectory_NullDestination_ThrowsArgumentNullException() + { + var mockArchive = GetMockZipArchive(new[] { "file1.txt" }); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + + Assert.That(() => systemUnderTest.ExtractToDirectory(mockArchive, null), Throws.TypeOf()); + } + [Test] + public void ExtractToDirectory_WhitespaceDestination_ThrowsArgumentException() + { + var mockArchive = GetMockZipArchive(new[] { "file1.txt" }); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + + Assert.Throws(() => systemUnderTest.ExtractToDirectory(mockArchive, " ")); + } + [Test] + public void ExtractToDirectory_EmptyArchive_CreatesEmptyDirectory() + { + var mockArchive = GetMockZipArchive(Array.Empty()); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + systemUnderTest.ExtractToDirectory(mockArchive, "/emptyExtract"); + + Assert.That(_mockFileSystem.Directory.Exists("/emptyExtract"), Is.True); + Assert.That(_mockFileSystem.Directory.GetFiles("/emptyExtract"), Is.Empty); + } + /// + /// Implementing tested with substituted mockFileSystem. It would be better if a mock file would be constructed using the TestingHelpers that we are not allowed to access. + /// + [Test] + public void ExtractToDirectory_NonWritableDirectory_ThrowsUnauthorizedAccessException() + { + var mockArchive = GetMockZipArchive(new[] { "file1.txt" }); + var mockFileSystem = Substitute.For(); + mockFileSystem.Directory.Exists("/nonWritable").Returns(true); + mockFileSystem.Directory.CreateDirectory(Arg.Any()).Returns(_ => throw new UnauthorizedAccessException("Simulated Unauthorized Access")); + + var systemUnderTest = new ZipExtensionWrapper(mockFileSystem); + + Assert.Throws(() => systemUnderTest.ExtractToDirectory(mockArchive, "/nonWritable")); + } + + + private ZipArchive GetMockZipArchive(string[] fileNames) + { + var memoryStream = new MemoryStream(); + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) + { + foreach (var fileName in fileNames) + { + var entry = archive.CreateEntry(fileName); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream); + writer.Write("Mock content"); + } + } + + memoryStream.Seek(0, SeekOrigin.Begin); + return new ZipArchive(memoryStream, ZipArchiveMode.Read); + } + + [Test] + public async Task CreateFromDirectoryAsync_NullSourcePath_ThrowsArgumentNullException() + { + string sourcePath = null; + string destinationPath = "/output.zip"; + + var systemUnderTest = CreateZipExtensionWrapper(); + + Assert.That( + async () => await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath), + Throws.ArgumentNullException); + } + + [Test] + public async Task CreateFromDirectoryAsync_NullDestinationPath_ThrowsArgumentNullException() + { + string sourcePath = "/source"; + string destinationPath = null; + + var systemUnderTest = CreateZipExtensionWrapper(); + Assert.That( + async () => await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath), + Throws.ArgumentNullException); + } + + [Test] + public async Task CreateFromDirectoryAsync_SourcePathDoesNotExist_ThrowsDirectoryNotFoundException() + { + string sourcePath = "/nonexistent"; + string destinationPath = "/output.zip"; + + var systemUnderTest = CreateZipExtensionWrapper(); + + Assert.That( + async () => await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath), + Throws.InstanceOf()); + } + + /// + /// Should be discussed further on 29.01. + /// + [Test] + public void CreateFromDirectoryAsync_NoWritePermission_ThrowsUnauthorizedAccessException() + { + string sourcePath = @"C:\source"; + string destinationPath = @"C:\output.zip"; + + _mockFileSystem.AddDirectory(sourcePath); + _mockFileSystem.AddFile(Path.Combine(sourcePath, "file1.txt"), new MockFileData("File1 content")); + _mockFileSystem.AddFile(destinationPath, new MockFileData("Existing content") + { + Attributes = FileAttributes.ReadOnly + }); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + var exception = Assert.ThrowsAsync(async () => + await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath)); + + Assert.That(exception, Is.Not.Null); + Assert.That(exception.Message, Contains.Substring("Access to the path")); + } + + + + [Test] + public async Task CreateFromDirectoryAsync_SubdirectoriesAreIncluded_CreatesArchiveWithHierarchy() +{ + string sourcePath = @"C:\source"; + string destinationPath = @"C:\output.zip"; + + string subdirectoryPath = Path.Combine(sourcePath, "subdir"); + _mockFileSystem.AddDirectory(sourcePath); + _mockFileSystem.AddDirectory(subdirectoryPath); + _mockFileSystem.AddFile(Path.Combine(sourcePath, "file1.txt"), new MockFileData("File1 content")); + _mockFileSystem.AddFile(Path.Combine(subdirectoryPath, "file2.txt"), new MockFileData("File2 content")); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath); + var archive = OpenZipArchive(destinationPath); + + Assert.That(_mockFileSystem.File.Exists(destinationPath), Is.True); + Assert.That(archive.Entries.Count, Is.EqualTo(2)); + Assert.That(archive.Entries.Any(e => e.FullName == "file1.txt"), Is.True); + Assert.That(archive.Entries.Any(e => e.FullName == "subdir/file2.txt" || e.FullName == @"subdir\file2.txt"), Is.True); +} + + [Test] + public async Task CreateFromDirectoryAsync_EmptySourceDirectory_CreatesEmptyArchive() + { + string sourcePath = @"C:\source"; + string destinationPath = @"C:\output.zip"; + _mockFileSystem.AddDirectory(sourcePath); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + + await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath); + var archive = OpenZipArchive(destinationPath); + + Assert.That(_mockFileSystem.File.Exists(destinationPath), Is.True); + Assert.That(archive.Entries.Count, Is.EqualTo(0)); + } + [Test] + public async Task CreateFromDirectoryAsync_EmptySourcePath_ThrowsArgumentException() + { + var systemUnderTest = CreateZipExtensionWrapper(); + + var exception = Assert.ThrowsAsync(async () => + await systemUnderTest.CreateFromDirectoryAsync(string.Empty, "/destination.zip")); + + Assert.That(exception!.ParamName, Is.EqualTo("path")); + } + + [Test] + public async Task CreateFromDirectoryAsync_EmptyDestinationPath_ThrowsArgumentException() + { + var systemUnderTest = CreateZipExtensionWrapper(); + + var exception = Assert.ThrowsAsync(async () => + await systemUnderTest.CreateFromDirectoryAsync("/source", string.Empty)); + + Assert.That(exception!.ParamName, Is.EqualTo("path")); + } + [Test] + public async Task CreateFromDirectoryAsync_WhitespaceSourcePath_ThrowsArgumentException() + { + var systemUnderTest = CreateZipExtensionWrapper(); + var exception = Assert.ThrowsAsync(async () => + await systemUnderTest.CreateFromDirectoryAsync(" ", "/destination.zip")); + + Assert.That(exception!.ParamName, Is.EqualTo("path")); + } + [Test] + public async Task CreateFromDirectoryAsync_WhitespaceDestinationPath_ThrowsArgumentException() + { + var systemUnderTest = CreateZipExtensionWrapper(); + var exception = Assert.ThrowsAsync(async () => + await systemUnderTest.CreateFromDirectoryAsync("/source", " ")); + + Assert.That(exception!.ParamName, Is.EqualTo("path")); + } + + [Test] + public async Task CreateFromDirectoryAsync_SourceContainsFiles_CreateArchiveWithFiles() +{ + string sourcePath = @"C:\source"; + string destinationPath = @"C:\output.zip"; + AddFilesToMockFileSystem(sourcePath, "File1 content", "File2 content"); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath); + var archive = OpenZipArchive(destinationPath); + + Assert.That(_mockFileSystem.File.Exists(destinationPath), Is.True); + Assert.That(archive.Entries.Count, Is.EqualTo(2)); + Assert.That(archive.Entries.Any(e => e.FullName == "file1.txt"), Is.True); + Assert.That(archive.Entries.Any(e => e.FullName == "file2.txt"), Is.True); +} + + + + [Test] + public async Task CreateFromDirectoryAsync_DestinationAlreadyExists_OverwritesFile() + { + string sourcePath = @"C:\source"; + string destinationPath = @"C:\output.zip"; + AddFilesToMockFileSystem(sourcePath, "file1content","file2content"); + _mockFileSystem.AddFile(destinationPath, new MockFileData("Existing content")); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath); + var archive = OpenZipArchive(destinationPath); + + Assert.That(_mockFileSystem.File.Exists(destinationPath), Is.True); + Assert.That(archive.Entries.Count, Is.EqualTo(2)); + Assert.That(archive.Entries.Any(e => e.FullName == "file1.txt"), Is.True); + Assert.That(archive.Entries.Any(e => e.FullName == "file2.txt"), Is.True); + } + [Test] + public async Task CreateFromDirectoryAsync_HiddenFilesInSource_IncludesHiddenFilesInArchive() + { + string sourcePath = @"C:\source"; + string destinationPath = @"C:\output.zip"; + _mockFileSystem.AddDirectory(sourcePath); + string hiddenFilePath = Path.Combine(sourcePath, "hidden.txt"); + _mockFileSystem.AddFile(hiddenFilePath, new MockFileData("Hidden file content") + { + Attributes = FileAttributes.Hidden + }); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath); + var archive = OpenZipArchive(destinationPath); + + Assert.That(_mockFileSystem.File.Exists(destinationPath), Is.True); + Assert.That(archive.Entries.Count, Is.EqualTo(1)); + Assert.That(archive.Entries.Any(e => e.FullName == "hidden.txt"), Is.True); + } + [Test] + public async Task CreateFromDirectoryAsync_SourcePathIsFile_ThrowsDirectoryNotFoundException() + { + string sourcePath = @"C:\source\file.txt"; + string destinationPath = @"C:\output.zip"; + _mockFileSystem.AddFile(sourcePath, new MockFileData("This is a file")); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + + Assert.That( + async () => await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath), + Throws.InstanceOf().With.Message.Contains("Could not find a part of the path")); + } + + [Test] + public async Task CreateFromDirectoryAsync_SymlinksInSource_IgnoresSymlinks() + { + string sourcePath = "/source"; + string destinationPath = "/output.zip"; + _mockFileSystem.AddDirectory(sourcePath); + _mockFileSystem.AddFile(Path.Combine(sourcePath, "file1.txt"), new MockFileData("File1 content")); + _mockFileSystem.AddFile(Path.Combine(sourcePath, "symlink"), new MockFileData(string.Empty) + { + Attributes = FileAttributes.ReparsePoint + }); + + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath); + var archive = OpenZipArchive(destinationPath); + + Assert.That(_mockFileSystem.File.Exists(destinationPath), Is.True); + Assert.That(archive.Entries.Count, Is.EqualTo(1)); + Assert.That(archive.Entries.Any(e => e.FullName == "file1.txt"), Is.True); + } + [Test] + public async Task CreateFromDirectoryAsync_SourceContainsReadOnlyFiles_Success() + { + var sourcePath = "/source"; + var destinationPath = "/destination.zip"; + _mockFileSystem.AddDirectory(sourcePath); + _mockFileSystem.AddFile(Path.Combine(sourcePath, "readonly.txt"), new MockFileData("Read-only content") + { + Attributes = FileAttributes.ReadOnly + }); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath); + var archive = OpenZipArchive(destinationPath); + + Assert.That(_mockFileSystem.File.Exists(destinationPath), Is.True); + Assert.That(archive.Entries.Any(e => e.FullName == "readonly.txt"), Is.True); + } + [Test] + public async Task CreateFromDirectoryAsync_SourceContainsFilesWithLongPaths_Success() + { + var sourcePath = "/source"; + var destinationPath = "/destination.zip"; + _mockFileSystem.AddDirectory(sourcePath); + var longFileName = new string('a', 255) + ".txt"; + _mockFileSystem.AddFile(Path.Combine(sourcePath, longFileName), new MockFileData("Long path content")); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath); + + Assert.That(_mockFileSystem.File.Exists(destinationPath), Is.True); + } + [Test] + public async Task CreateFromDirectoryAsync_SourceContainsSpecialCharacterFiles_Success() + { + var sourcePath = "/source"; + var destinationPath = "/destination.zip"; + _mockFileSystem.AddDirectory(sourcePath); + _mockFileSystem.AddFile(Path.Combine(sourcePath, "file@#$!.txt"), new MockFileData("Special characters")); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath); + var archive = OpenZipArchive(destinationPath); + + Assert.That(_mockFileSystem.File.Exists(destinationPath), Is.True); + Assert.That(archive.Entries.Any(e => e.FullName == "file@#$!.txt"), Is.True); + } + [Test] + public async Task CreateFromDirectoryAsync_FilesAddedDuringOperation_IgnoresNewFiles() + { + var sourcePath = "/source"; + var destinationPath = "/destination.zip"; + _mockFileSystem.AddDirectory(sourcePath); + _mockFileSystem.AddFile(Path.Combine(sourcePath, "file1.txt"), new MockFileData("Initial content")); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + var task = systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath); + await Task.Delay(10); + _mockFileSystem.AddFile(Path.Combine(sourcePath, "file2.txt"), new MockFileData("New content")); + await task; + var archive = OpenZipArchive(destinationPath); + + Assert.That(_mockFileSystem.File.Exists(destinationPath), Is.True); + Assert.That(archive.Entries.Count, Is.EqualTo(1)); // Only file1.txt should be included + Assert.That(archive.Entries.Any(e => e.FullName == "file1.txt"), Is.True); + } + [Test] + public async Task CreateFromDirectoryAsync_FilesRemovedDuringOperation_DoesNotFail() + { + var sourcePath = "/source"; + var destinationPath = "/destination.zip"; + _mockFileSystem.AddDirectory(sourcePath); + _mockFileSystem.AddFile(Path.Combine(sourcePath, "file1.txt"), new MockFileData("Initial content")); + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + var task = systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath); + await Task.Delay(10); + _mockFileSystem.RemoveFile(Path.Combine(sourcePath, "file1.txt")); + await task; + var archive = OpenZipArchive(destinationPath); + + Assert.That(_mockFileSystem.File.Exists(destinationPath), Is.True); + Assert.That(archive.Entries.Count, Is.EqualTo(1)); // Expect file1.txt to be included + Assert.That(archive.Entries.Any(e => e.FullName == "file1.txt"), Is.True); + } + [Test] + public async Task CreateFromDirectoryAsync_DestinationDriveOutOfSpace_ThrowsIOException() + { + var sourcePath = "/source"; + var destinationPath = "/destination.zip"; + _mockFileSystem.AddDirectory(sourcePath); + _mockFileSystem.AddFile(Path.Combine(sourcePath, "file.txt"), new MockFileData("Some content")); + var limitedFileSystem = Substitute.For(); + limitedFileSystem.FileSystemWatcher.Returns(_mockFileSystem.FileSystemWatcher); + limitedFileSystem.Directory.Returns(_mockFileSystem.Directory); + limitedFileSystem.File.OpenWrite(Arg.Any()).Returns(ci => throw new IOException("No space left on device")); + + var systemUnderTest = CreateZipExtensionWrapper(limitedFileSystem); + + Assert.ThrowsAsync(async () => + await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath)); + } + + [Test] + public async Task CreateFromDirectoryAsync_SourceContainsVeryLargeFiles_Success() + { + var sourcePath = "/source"; + var destinationPath = "/destination.zip"; + _mockFileSystem.AddDirectory(sourcePath); + _mockFileSystem.AddFile(Path.Combine(sourcePath, "largefile.bin"), new MockFileData(new byte[1024 * 1024 * 500])); // 500MB + + var systemUnderTest = CreateZipExtensionWrapper(_mockFileSystem); + await systemUnderTest.CreateFromDirectoryAsync(sourcePath, destinationPath); + var archive = OpenZipArchive(destinationPath); + + Assert.That(_mockFileSystem.File.Exists(destinationPath), Is.True); + Assert.That(archive.Entries.Count, Is.EqualTo(1)); + Assert.That(archive.Entries.Any(e => e.FullName == "largefile.bin"), Is.True); + } + + + private void AddFilesToMockFileSystem(string path, string content, string content2) + { + _mockFileSystem.AddDirectory(path); + _mockFileSystem.AddFile(Path.Combine(path, "file1.txt"), new MockFileData(content)); + _mockFileSystem.AddFile(Path.Combine(path, "file2.txt"), new MockFileData(content2)); + } + + private ZipArchive OpenZipArchive(string path) + { + var stream = _mockFileSystem.File.OpenRead(path); + return new ZipArchive(stream, ZipArchiveMode.Read); + } + + +} \ No newline at end of file