diff --git a/README.md b/README.md index acf5b3a5e..23c4a6573 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,85 @@ public class SomeClassUsingFileSystemWatcher } ``` +### MockFileSystem Events + +The `MockFileSystem` in the testing helpers now supports an event system that allows you to observe file system operations and simulate errors: + +```csharp +[Test] +public void Test_SimulateDiskFullError() +{ + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + + using (fileSystem.Events.Subscribe(FileOperation.Write, args => + { + if (args.Phase == OperationPhase.Before) + { + args.SetResponse(new OperationResponse + { + Exception = new IOException("There is not enough space on the disk.") + }); + } + })) + { + Assert.Throws(() => fileSystem.File.WriteAllText(@"C:\test.txt", "content")); + } +} + +[Test] +public void Test_TrackFileOperations() +{ + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var operations = new List(); + + using (fileSystem.Events.Subscribe(args => + { + if (args.Phase == OperationPhase.After) + { + operations.Add($"{args.Operation} {args.Path}"); + } + })) + { + fileSystem.File.Create(@"C:\test.txt").Dispose(); + fileSystem.File.WriteAllText(@"C:\test.txt", "content"); + fileSystem.File.Delete(@"C:\test.txt"); + + Assert.That(operations, Is.EqualTo(new[] { + "Create C:\\test.txt", + "Write C:\\test.txt", + "Delete C:\\test.txt" + })); + } +} + +[Test] +public void Test_SimulateFileInUse() +{ + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + fileSystem.AddFile(@"C:\locked.db", "data"); + + // Simulate file locking for .db files + using (fileSystem.Events.Subscribe(args => + { + if (args.Phase == OperationPhase.Before && + args.Path.EndsWith(".db") && + args.Operation == FileOperation.Delete) + { + args.SetResponse(new OperationResponse + { + Exception = new IOException("The file is in use.") + }); + } + })) + { + var exception = Assert.Throws(() => fileSystem.File.Delete(@"C:\locked.db")); + Assert.That(exception.Message, Is.EqualTo("The file is in use.")); + } +} +``` + +The event system is opt-in and has almost zero overhead when not enabled. Enable it by setting `EnableEvents = true` in `MockFileSystemOptions`. + ## Related projects - [`System.IO.Abstractions.Extensions`](https://github.com/TestableIO/System.IO.Abstractions.Extensions) diff --git a/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/FileOperation.cs b/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/FileOperation.cs new file mode 100644 index 000000000..b8865b719 --- /dev/null +++ b/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/FileOperation.cs @@ -0,0 +1,57 @@ +namespace System.IO.Abstractions.TestingHelpers.Events; + +/// +/// Represents the type of file system operation. +/// +public enum FileOperation +{ + /// + /// File or directory creation operation. + /// + Create, + + /// + /// File open operation. + /// + Open, + + /// + /// File write operation. + /// + Write, + + /// + /// File read operation. + /// + Read, + + /// + /// File or directory deletion operation. + /// + Delete, + + /// + /// File or directory move operation. + /// + Move, + + /// + /// File or directory copy operation. + /// + Copy, + + /// + /// Set attributes operation. + /// + SetAttributes, + + /// + /// Set file times operation. + /// + SetTimes, + + /// + /// Set permissions operation. + /// + SetPermissions +} \ No newline at end of file diff --git a/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/FileSystemOperationEventArgs.cs b/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/FileSystemOperationEventArgs.cs new file mode 100644 index 000000000..542a69cbd --- /dev/null +++ b/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/FileSystemOperationEventArgs.cs @@ -0,0 +1,68 @@ +namespace System.IO.Abstractions.TestingHelpers.Events; + +/// +/// Provides data for file system operation events. +/// +public class FileSystemOperationEventArgs : EventArgs +{ + private OperationResponse response; + + /// + /// Initializes a new instance of the class. + /// + /// The path of the resource being operated on. + /// The type of operation. + /// The type of resource. + /// The phase of the operation. + public FileSystemOperationEventArgs( + string path, + FileOperation operation, + ResourceType resourceType, + OperationPhase phase) + { + Path = path ?? throw new ArgumentNullException(nameof(path)); + Operation = operation; + ResourceType = resourceType; + Phase = phase; + } + + /// + /// Gets the path of the resource being operated on. + /// + public string Path { get; } + + /// + /// Gets the type of operation being performed. + /// + public FileOperation Operation { get; } + + /// + /// Gets the type of resource being operated on. + /// + public ResourceType ResourceType { get; } + + /// + /// Gets the phase of the operation. + /// + public OperationPhase Phase { get; } + + /// + /// Sets a response for the operation. Only valid for Before phase events. + /// + /// The response to set. + /// Thrown when called on an After phase event. + public void SetResponse(OperationResponse response) + { + if (Phase != OperationPhase.Before) + { + throw new InvalidOperationException("Response can only be set for Before phase events."); + } + + this.response = response ?? throw new ArgumentNullException(nameof(response)); + } + + /// + /// Gets the response set for this operation, if any. + /// + internal OperationResponse GetResponse() => response; +} \ No newline at end of file diff --git a/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/MockFileSystemEvents.cs b/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/MockFileSystemEvents.cs new file mode 100644 index 000000000..1321af327 --- /dev/null +++ b/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/MockFileSystemEvents.cs @@ -0,0 +1,394 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace System.IO.Abstractions.TestingHelpers.Events; + +/// +/// Provides event functionality for MockFileSystem operations. +/// +/// +/// +/// // Subscribe to all operations +/// var fs = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); +/// var subscription = fs.Events.Subscribe(args => +/// { +/// Console.WriteLine($"{args.Operation} {args.Path}"); +/// }); +/// +/// // Subscribe to specific operations +/// var writeSub = fs.Events.Subscribe(FileOperation.Write, args => +/// { +/// if (args.Phase == OperationPhase.Before) +/// { +/// args.SetResponse(new OperationResponse +/// { +/// Exception = new IOException("Disk full") +/// }); +/// } +/// }); +/// +/// // Unsubscribe when done +/// subscription.Dispose(); +/// +/// +#if FEATURE_SERIALIZABLE +[Serializable] +#endif +public class MockFileSystemEvents +{ +#if FEATURE_SERIALIZABLE + /// + /// Represents the collection of active event subscriptions for handling file system operations. + /// + [NonSerialized] +#endif + private readonly List subscriptions = []; + + /// + /// Indicates whether event handling is currently enabled for file system operations. + /// + private volatile bool isEnabled; + + /// + /// Tracks the current version of active subscriptions, allowing for the detection of changes + /// such as additions or removals of subscription handlers. + /// + private volatile int subscriptionVersion; + + /// + /// Gets the synchronization object used to manage thread safety for event handling operations. + /// + private object LockObject { get; } = new object(); + + /// + /// Gets a value indicating whether events are enabled. + /// + public bool IsEnabled => isEnabled; + + /// + /// Subscribes to all file system operations. + /// + /// The handler to invoke for each operation. + /// A disposable that removes the subscription when disposed. + public IDisposable Subscribe(Action handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + lock (LockObject) + { + var subscription = new Subscription(handler, null, this); + subscriptions.Add(subscription); + Interlocked.Increment(ref subscriptionVersion); + return subscription; + } + } + + /// + /// Subscribes to a specific file system operation. + /// + /// The operation to subscribe to. + /// The handler to invoke for the operation. + /// A disposable that removes the subscription when disposed. + public IDisposable Subscribe(FileOperation operation, Action handler) + { + if (handler == null) { + throw new ArgumentNullException(nameof(handler)); + } + + lock (LockObject) + { + var subscription = new Subscription(handler, new HashSet { operation }, this); + subscriptions.Add(subscription); + Interlocked.Increment(ref subscriptionVersion); + return subscription; + } + } + + /// + /// Subscribes to multiple specific file system operations. + /// + /// The operations to subscribe to. + /// The handler to invoke for the operations. + /// A disposable that removes the subscription when disposed. + public IDisposable Subscribe(FileOperation[] operations, Action handler) + { + if (handler == null) { + throw new ArgumentNullException(nameof(handler)); + } + + if (operations == null) + { + throw new ArgumentNullException(nameof(operations)); + } + + lock (LockObject) + { + var subscription = new Subscription(handler, new HashSet(operations), this); + subscriptions.Add(subscription); + Interlocked.Increment(ref subscriptionVersion); + return subscription; + } + } + + /// + /// Enables event handling for file system operations in the mock file system. + /// + internal void Enable() => isEnabled = true; + + /// + /// Wraps an operation with Before/After events, handling exceptions properly. + /// + /// The return type of the operation + /// The file path + /// The type of operation + /// The type of resource + /// The operation to execute + /// The result of the operation + public T WithEvents(string path, FileOperation operation, ResourceType resourceType, Func func) + { + if (!isEnabled) + { + return func(); + } + + RaiseOperation(path, operation, resourceType, OperationPhase.Before); + + var operationCompleted = false; + try + { + var result = func(); + operationCompleted = true; + return result; + } + finally + { + if (operationCompleted) + { + try + { + RaiseOperation(path, operation, resourceType, OperationPhase.After); + } + catch + { + // Don't let After event exceptions mask the main operation + } + } + } + } + + /// + /// Wraps a void operation with Before/After events, handling exceptions properly. + /// + /// The file path + /// The type of operation + /// The type of resource + /// The operation to execute + public void WithEvents(string path, FileOperation operation, ResourceType resourceType, Action action) + { + if (!isEnabled) + { + action(); + return; + } + + RaiseOperation(path, operation, resourceType, OperationPhase.Before); + + var operationCompleted = false; + try + { + action(); + operationCompleted = true; + } + finally + { + if (operationCompleted) + { + try + { + RaiseOperation(path, operation, resourceType, OperationPhase.After); + } + catch + { + // Don't let After event exceptions mask the main operation + } + } + } + } + + /// + /// Raises an operation event if events are enabled. + /// + internal void RaiseOperation( + string path, + FileOperation operation, + ResourceType resourceType, + OperationPhase phase) + { + if (!isEnabled) { + return; + } + + var args = new FileSystemOperationEventArgs(path, operation, resourceType, phase); + Subscription[] currentSubscriptions; + + lock (LockObject) + { + if (subscriptions.Count == 0) + { + return; + } + currentSubscriptions = subscriptions.ToArray(); + } + + var exceptions = new List(); + + foreach (var sub in currentSubscriptions.Where(sub => !sub.IsDisposed && sub.ShouldHandle(operation))) + { + try + { + sub.Handler(args); + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + + if (exceptions.Count > 0) + { + if (exceptions.Count == 1) + { + throw exceptions[0]; + } + throw new AggregateException( + $"One or more event handlers failed for {operation} operation on {path}", + exceptions); + } + + // Handle responses only for Before phase + if (phase != OperationPhase.Before) + { + return; + } + var response = args.GetResponse(); + if (response == null) + { + return; + } + + if (response.Exception != null) + { + throw response.Exception; + } + + if (response.Cancel) + { + throw new OperationCanceledException( + $"Operation {operation} on {path} was cancelled."); + } + } + + /// + /// Removes the specified subscription from the list of active subscriptions. + /// + /// The subscription to be removed. + private void RemoveSubscription(Subscription subscription) + { + lock (LockObject) + { + if (!subscriptions.Remove(subscription)) + { + return; + } + Interlocked.Increment(ref subscriptionVersion); + } + } + +#if FEATURE_SERIALIZABLE + [Serializable] +#endif + private class Subscription : IDisposable + { +#if FEATURE_SERIALIZABLE + /// + /// Represents a reference to the parent instance + /// that manages the subscriptions and events for the file system operations. + /// + [NonSerialized] +#endif + private readonly MockFileSystemEvents parent; + /// + /// Specifies the set of file system operations that this subscription filters on. + /// + /// + /// When set, only the specified file operations will trigger the event handler. + /// If null, all file system operations are considered for this subscription. + /// + private readonly HashSet filterOperations; + + /// + /// Indicates whether the subscription has been disposed. + /// + private volatile bool isDisposed; + +#if FEATURE_SERIALIZABLE + [NonSerialized] +#endif + public readonly Action Handler; + /// + /// Gets a value indicating whether this subscription has been disposed. + /// + public bool IsDisposed + { + get + { + return isDisposed; + } + } + + /// + /// Represents a subscription to file system events within a mock file system. + /// + /// + /// A subscription tracks a handler and optional filters for specific file operations and + /// connects to a parent instance. + /// + public Subscription(Action handler, + HashSet filterOperations, MockFileSystemEvents parent) + { + Handler = handler ?? throw new ArgumentNullException(nameof(handler)); + this.filterOperations = filterOperations; + this.parent = parent ?? throw new ArgumentNullException(nameof(parent)); + } + + /// + /// Determines whether the specified file operation should be handled based on the defined filters. + /// + /// The file operation to evaluate. + /// true if the operation should be handled; otherwise, false. + public bool ShouldHandle(FileOperation operation) + { + return filterOperations == null || filterOperations.Contains(operation); + } + + /// + /// Disposes of this subscription, unregistering it from the parent instance. + /// + /// + /// Once disposed, the subscription is no longer valid and will be removed from the list of active subscriptions. + /// + public void Dispose() + { + if (isDisposed) + { + return; + } + isDisposed = true; + parent.RemoveSubscription(this); + } + } +} diff --git a/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/OperationPhase.cs b/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/OperationPhase.cs new file mode 100644 index 000000000..558c5deed --- /dev/null +++ b/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/OperationPhase.cs @@ -0,0 +1,17 @@ +namespace System.IO.Abstractions.TestingHelpers.Events; + +/// +/// Represents the phase of an operation. +/// +public enum OperationPhase +{ + /// + /// Before the operation is executed. Allows cancellation or throwing exceptions. + /// + Before, + + /// + /// After the operation has been executed. For notification only. + /// + After +} \ No newline at end of file diff --git a/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/OperationResponse.cs b/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/OperationResponse.cs new file mode 100644 index 000000000..841331b39 --- /dev/null +++ b/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/OperationResponse.cs @@ -0,0 +1,19 @@ +namespace System.IO.Abstractions.TestingHelpers.Events; + +/// +/// Represents a response to a file system operation that can cancel or modify the operation. +/// +public class OperationResponse +{ + /// + /// Gets or sets a value indicating whether the operation should be cancelled. + /// Only applies to Before phase events. + /// + public bool Cancel { get; set; } + + /// + /// Gets or sets an exception to throw instead of executing the operation. + /// Only applies to Before phase events. + /// + public Exception Exception { get; set; } +} \ No newline at end of file diff --git a/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/ResourceType.cs b/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/ResourceType.cs new file mode 100644 index 000000000..4004172e0 --- /dev/null +++ b/src/TestableIO.System.IO.Abstractions.TestingHelpers/Events/ResourceType.cs @@ -0,0 +1,17 @@ +namespace System.IO.Abstractions.TestingHelpers.Events; + +/// +/// Represents the type of file system resource. +/// +public enum ResourceType +{ + /// + /// The resource is a file. + /// + File, + + /// + /// The resource is a directory. + /// + Directory +} \ No newline at end of file diff --git a/src/TestableIO.System.IO.Abstractions.TestingHelpers/IMockFileDataAccessor.cs b/src/TestableIO.System.IO.Abstractions.TestingHelpers/IMockFileDataAccessor.cs index ba479cdd8..3447e71fe 100644 --- a/src/TestableIO.System.IO.Abstractions.TestingHelpers/IMockFileDataAccessor.cs +++ b/src/TestableIO.System.IO.Abstractions.TestingHelpers/IMockFileDataAccessor.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Reflection; +using System.IO.Abstractions.TestingHelpers.Events; namespace System.IO.Abstractions.TestingHelpers; @@ -110,4 +111,9 @@ public interface IMockFileDataAccessor : IFileSystem /// Gets a reference to the underlying file system. /// IFileSystem FileSystem { get; } + + /// + /// Gets the event system for this file system. + /// + MockFileSystemEvents Events { get; } } \ No newline at end of file diff --git a/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockDirectory.cs b/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockDirectory.cs index 242bb0d5e..ebc25cd47 100644 --- a/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockDirectory.cs +++ b/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockDirectory.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Linq; using System.Text.RegularExpressions; +using System.IO.Abstractions.TestingHelpers.Events; namespace System.IO.Abstractions.TestingHelpers; @@ -73,9 +74,41 @@ private IDirectoryInfo CreateDirectoryInternal(string path) } var existingFile = mockFileDataAccessor.GetFile(path); + bool operationCompleted = false; + + // Fire Before event only if directory doesn't exist if (existingFile == null) { - mockFileDataAccessor.AddDirectory(path); + mockFileDataAccessor.Events.RaiseOperation( + path, + FileOperation.Create, + ResourceType.Directory, + OperationPhase.Before); + + try + { + mockFileDataAccessor.AddDirectory(path); + operationCompleted = true; + } + finally + { + if (operationCompleted) + { + try + { + // Fire After event + mockFileDataAccessor.Events.RaiseOperation( + path, + FileOperation.Create, + ResourceType.Directory, + OperationPhase.After); + } + catch + { + // Don't let After event exceptions mask the main operation + } + } + } } else if (!existingFile.IsDirectory) { @@ -137,36 +170,67 @@ public override void Delete(string path) public override void Delete(string path, bool recursive) { path = mockFileDataAccessor.Path.GetFullPath(path).TrimSlashes(); + bool operationCompleted = false; - var stringOps = mockFileDataAccessor.StringOperations; - var pathWithDirectorySeparatorChar = $"{path}{Path.DirectorySeparatorChar}"; + // Fire Before event + mockFileDataAccessor.Events.RaiseOperation( + path, + FileOperation.Delete, + ResourceType.Directory, + OperationPhase.Before); - var affectedPaths = mockFileDataAccessor - .AllPaths - .Where(p => stringOps.Equals(p, path) || stringOps.StartsWith(p, pathWithDirectorySeparatorChar)) - .ToList(); - - if (!affectedPaths.Any()) + try { - throw CommonExceptions.PathDoesNotExistOrCouldNotBeFound(path); - } + var stringOps = mockFileDataAccessor.StringOperations; + var pathWithDirectorySeparatorChar = $"{path}{Path.DirectorySeparatorChar}"; - if (!recursive && affectedPaths.Count > 1) - { - throw new IOException("The directory specified by " + path + - " is read-only, or recursive is false and " + path + - " is not an empty directory."); - } + var affectedPaths = mockFileDataAccessor + .AllPaths + .Where(p => stringOps.Equals(p, path) || stringOps.StartsWith(p, pathWithDirectorySeparatorChar)) + .ToList(); - bool isFile = !mockFileDataAccessor.GetFile(path).IsDirectory; - if (isFile) - { - throw new IOException("The directory name is invalid."); - } + if (!affectedPaths.Any()) + { + throw CommonExceptions.PathDoesNotExistOrCouldNotBeFound(path); + } + + if (!recursive && affectedPaths.Count > 1) + { + throw new IOException("The directory specified by " + path + + " is read-only, or recursive is false and " + path + + " is not an empty directory."); + } + + bool isFile = !mockFileDataAccessor.GetFile(path).IsDirectory; + if (isFile) + { + throw new IOException("The directory name is invalid."); + } - foreach (var affectedPath in affectedPaths) + foreach (var affectedPath in affectedPaths) + { + mockFileDataAccessor.RemoveFile(affectedPath); + } + operationCompleted = true; + } + finally { - mockFileDataAccessor.RemoveFile(affectedPath); + if (operationCompleted) + { + try + { + // Fire After event + mockFileDataAccessor.Events.RaiseOperation( + path, + FileOperation.Delete, + ResourceType.Directory, + OperationPhase.After); + } + catch + { + // Don't let After event exceptions mask the main operation + } + } } } @@ -544,7 +608,12 @@ public override void Move(string sourceDirName, string destDirName) throw CommonExceptions.CannotCreateBecauseSameNameAlreadyExists(fullDestPath); } } - mockFileDataAccessor.MoveDirectory(fullSourcePath, fullDestPath); + + // Fire Move events for directory move operation + mockFileDataAccessor.Events.WithEvents(fullSourcePath, FileOperation.Move, ResourceType.Directory, () => + { + mockFileDataAccessor.MoveDirectory(fullSourcePath, fullDestPath); + }); } #if FEATURE_CREATE_SYMBOLIC_LINK @@ -595,13 +664,23 @@ public override IFileSystemInfo ResolveLinkTarget(string linkPath, bool returnFi /// public override void SetCreationTime(string path, DateTime creationTime) { - mockFileDataAccessor.File.SetCreationTime(path, creationTime); + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.SetTimes, ResourceType.Directory, () => + { + mockFileDataAccessor.GetFile(path).CreationTime = new DateTimeOffset(creationTime); + }); } /// public override void SetCreationTimeUtc(string path, DateTime creationTimeUtc) { - mockFileDataAccessor.File.SetCreationTimeUtc(path, creationTimeUtc); + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.SetTimes, ResourceType.Directory, () => + { + mockFileDataAccessor.GetFile(path).CreationTime = new DateTimeOffset(creationTimeUtc, TimeSpan.Zero); + }); } /// @@ -613,25 +692,45 @@ public override void SetCurrentDirectory(string path) /// public override void SetLastAccessTime(string path, DateTime lastAccessTime) { - mockFileDataAccessor.File.SetLastAccessTime(path, lastAccessTime); + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.SetTimes, ResourceType.Directory, () => + { + mockFileDataAccessor.GetFile(path).LastAccessTime = new DateTimeOffset(lastAccessTime); + }); } /// public override void SetLastAccessTimeUtc(string path, DateTime lastAccessTimeUtc) { - mockFileDataAccessor.File.SetLastAccessTimeUtc(path, lastAccessTimeUtc); + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.SetTimes, ResourceType.Directory, () => + { + mockFileDataAccessor.GetFile(path).LastAccessTime = new DateTimeOffset(lastAccessTimeUtc, TimeSpan.Zero); + }); } /// public override void SetLastWriteTime(string path, DateTime lastWriteTime) { - mockFileDataAccessor.File.SetLastWriteTime(path, lastWriteTime); + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.SetTimes, ResourceType.Directory, () => + { + mockFileDataAccessor.GetFile(path).LastWriteTime = new DateTimeOffset(lastWriteTime); + }); } /// public override void SetLastWriteTimeUtc(string path, DateTime lastWriteTimeUtc) { - mockFileDataAccessor.File.SetLastWriteTimeUtc(path, lastWriteTimeUtc); + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.SetTimes, ResourceType.Directory, () => + { + mockFileDataAccessor.GetFile(path).LastWriteTime = new DateTimeOffset(lastWriteTimeUtc, TimeSpan.Zero); + }); } /// diff --git a/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFile.cs b/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFile.cs index 0f9a97a48..3a53e89ea 100644 --- a/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFile.cs +++ b/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFile.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text; using Microsoft.Win32.SafeHandles; +using System.IO.Abstractions.TestingHelpers.Events; namespace System.IO.Abstractions.TestingHelpers; @@ -28,18 +29,23 @@ public override void AppendAllBytes(string path, byte[] bytes) { mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(path, "path"); - if (!mockFileDataAccessor.FileExists(path)) - { - VerifyDirectoryExists(path); - mockFileDataAccessor.AddFile(path, mockFileDataAccessor.AdjustTimes(new MockFileData(bytes), TimeAdjustments.All)); - } - else + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.Write, ResourceType.File, () => { - var file = mockFileDataAccessor.GetFile(path); - file.CheckFileAccess(path, FileAccess.Write); - mockFileDataAccessor.AdjustTimes(file, TimeAdjustments.LastAccessTime | TimeAdjustments.LastWriteTime); - file.Contents = file.Contents.Concat(bytes).ToArray(); - } + if (!mockFileDataAccessor.FileExists(path)) + { + VerifyDirectoryExists(path); + mockFileDataAccessor.AddFile(path, mockFileDataAccessor.AdjustTimes(new MockFileData(bytes), TimeAdjustments.All)); + } + else + { + var file = mockFileDataAccessor.GetFile(path); + file.CheckFileAccess(path, FileAccess.Write); + mockFileDataAccessor.AdjustTimes(file, TimeAdjustments.LastAccessTime | TimeAdjustments.LastWriteTime); + file.Contents = file.Contents.Concat(bytes).ToArray(); + } + }); } /// @@ -82,19 +88,24 @@ public override void AppendAllText(string path, string contents, Encoding encodi throw new ArgumentNullException(nameof(encoding)); } - if (!mockFileDataAccessor.FileExists(path)) + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.Write, ResourceType.File, () => { - VerifyDirectoryExists(path); - mockFileDataAccessor.AddFile(path, mockFileDataAccessor.AdjustTimes(new MockFileData(contents, encoding), TimeAdjustments.All)); - } - else - { - var file = mockFileDataAccessor.GetFile(path); - file.CheckFileAccess(path, FileAccess.Write); - mockFileDataAccessor.AdjustTimes(file, TimeAdjustments.LastAccessTime | TimeAdjustments.LastWriteTime); - var bytesToAppend = encoding.GetBytes(contents); - file.Contents = file.Contents.Concat(bytesToAppend).ToArray(); - } + if (!mockFileDataAccessor.FileExists(path)) + { + VerifyDirectoryExists(path); + mockFileDataAccessor.AddFile(path, mockFileDataAccessor.AdjustTimes(new MockFileData(contents, encoding), TimeAdjustments.All)); + } + else + { + var file = mockFileDataAccessor.GetFile(path); + file.CheckFileAccess(path, FileAccess.Write); + mockFileDataAccessor.AdjustTimes(file, TimeAdjustments.LastAccessTime | TimeAdjustments.LastWriteTime); + var bytesToAppend = encoding.GetBytes(contents); + file.Contents = file.Contents.Concat(bytesToAppend).ToArray(); + } + }); } #if FEATURE_FILE_SPAN @@ -148,34 +159,39 @@ public override void Copy(string sourceFileName, string destFileName, bool overw mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(sourceFileName, nameof(sourceFileName)); mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(destFileName, nameof(destFileName)); - if (!Exists(sourceFileName)) - { - throw CommonExceptions.FileNotFound(sourceFileName); - } - - VerifyDirectoryExists(destFileName); - - var fileExists = mockFileDataAccessor.FileExists(destFileName); - if (fileExists) + var fullDestPath = mockFileDataAccessor.Path.GetFullPath(destFileName); + + mockFileDataAccessor.Events.WithEvents(fullDestPath, FileOperation.Copy, ResourceType.File, () => { - if (!overwrite) + if (!Exists(sourceFileName)) { - throw CommonExceptions.FileAlreadyExists(destFileName); + throw CommonExceptions.FileNotFound(sourceFileName); } - if (string.Equals(sourceFileName, destFileName, StringComparison.OrdinalIgnoreCase) && XFS.IsWindowsPlatform()) + VerifyDirectoryExists(destFileName); + + var fileExists = mockFileDataAccessor.FileExists(destFileName); + if (fileExists) { - throw CommonExceptions.ProcessCannotAccessFileInUse(destFileName); - } + if (!overwrite) + { + throw CommonExceptions.FileAlreadyExists(destFileName); + } - mockFileDataAccessor.RemoveFile(destFileName); - } + if (string.Equals(sourceFileName, destFileName, StringComparison.OrdinalIgnoreCase) && XFS.IsWindowsPlatform()) + { + throw CommonExceptions.ProcessCannotAccessFileInUse(destFileName); + } - var sourceFileData = mockFileDataAccessor.GetFile(sourceFileName); - sourceFileData.CheckFileAccess(sourceFileName, FileAccess.Read); - var destFileData = new MockFileData(sourceFileData); - mockFileDataAccessor.AdjustTimes(destFileData, TimeAdjustments.CreationTime | TimeAdjustments.LastAccessTime); - mockFileDataAccessor.AddFile(destFileName, destFileData); + mockFileDataAccessor.RemoveFile(destFileName); + } + + var sourceFileData = mockFileDataAccessor.GetFile(sourceFileName); + sourceFileData.CheckFileAccess(sourceFileName, FileAccess.Read); + var destFileData = new MockFileData(sourceFileData); + mockFileDataAccessor.AdjustTimes(destFileData, TimeAdjustments.CreationTime | TimeAdjustments.LastAccessTime); + mockFileDataAccessor.AddFile(destFileName, destFileData); + }); } /// @@ -198,12 +214,19 @@ private FileSystemStream CreateInternal(string path, FileAccess access, FileOpti } mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(path, nameof(path)); - VerifyDirectoryExists(path); + + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + return mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.Create, ResourceType.File, () => + { + VerifyDirectoryExists(path); - var mockFileData = new MockFileData(new byte[0]); - mockFileDataAccessor.AdjustTimes(mockFileData, TimeAdjustments.All); - mockFileDataAccessor.AddFile(path, mockFileData); - return OpenInternal(path, FileMode.Open, access, options); + var mockFileData = new MockFileData(new byte[0]); + mockFileDataAccessor.AdjustTimes(mockFileData, TimeAdjustments.All); + mockFileDataAccessor.AddFile(path, mockFileData); + + return OpenInternal(path, FileMode.Open, access, options); + }); } #if FEATURE_CREATE_SYMBOLIC_LINK @@ -266,23 +289,36 @@ public override void Delete(string path) { mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(path, "path"); - // We mimic exact behavior of the standard File.Delete() method - // which throws exception only if the folder does not exist, - // but silently returns if deleting a non-existing file in an existing folder. - VerifyDirectoryExists(path); - + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + var file = mockFileDataAccessor.GetFile(path); - if (file != null && !file.AllowedFileShare.HasFlag(FileShare.Delete)) + if (file != null) { - throw CommonExceptions.ProcessCannotAccessFileInUse(path); - } + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.Delete, ResourceType.File, () => + { + // We mimic exact behavior of the standard File.Delete() method + // which throws exception only if the folder does not exist, + // but silently returns if deleting a non-existing file in an existing folder. + VerifyDirectoryExists(path); + + if (!file.AllowedFileShare.HasFlag(FileShare.Delete)) + { + throw CommonExceptions.ProcessCannotAccessFileInUse(path); + } - if (file != null && file.IsDirectory) + if (file.IsDirectory) + { + throw new UnauthorizedAccessException($"Access to the path '{path}' is denied."); + } + + mockFileDataAccessor.RemoveFile(path); + }); + } + else { - throw new UnauthorizedAccessException($"Access to the path '{path}' is denied."); + // Just verify directory exists when file doesn't exist (standard behavior) + VerifyDirectoryExists(path); } - - mockFileDataAccessor.RemoveFile(path); } /// @@ -527,39 +563,44 @@ public override void Move(string sourceFileName, string destFileName) mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(sourceFileName, nameof(sourceFileName)); mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(destFileName, nameof(destFileName)); - var sourceFile = mockFileDataAccessor.GetFile(sourceFileName); - - if (sourceFile == null) + var fullDestPath = mockFileDataAccessor.Path.GetFullPath(destFileName); + + mockFileDataAccessor.Events.WithEvents(fullDestPath, FileOperation.Move, ResourceType.File, () => { - throw CommonExceptions.FileNotFound(sourceFileName); - } + var sourceFile = mockFileDataAccessor.GetFile(sourceFileName); - if (!sourceFile.AllowedFileShare.HasFlag(FileShare.Delete)) - { - throw CommonExceptions.ProcessCannotAccessFileInUse(); - } + if (sourceFile == null) + { + throw CommonExceptions.FileNotFound(sourceFileName); + } - VerifyDirectoryExists(destFileName); + if (!sourceFile.AllowedFileShare.HasFlag(FileShare.Delete)) + { + throw CommonExceptions.ProcessCannotAccessFileInUse(); + } - if (mockFileDataAccessor.GetFile(destFileName) != null) - { - if (mockFileDataAccessor.StringOperations.Equals(destFileName, sourceFileName)) + VerifyDirectoryExists(destFileName); + + if (mockFileDataAccessor.GetFile(destFileName) != null) { - if (XFS.IsWindowsPlatform()) + if (mockFileDataAccessor.StringOperations.Equals(destFileName, sourceFileName)) + { + if (XFS.IsWindowsPlatform()) + { + mockFileDataAccessor.RemoveFile(sourceFileName); + mockFileDataAccessor.AddFile(destFileName, mockFileDataAccessor.AdjustTimes(new MockFileData(sourceFile), TimeAdjustments.LastAccessTime), false); + } + return; + } + else { - mockFileDataAccessor.RemoveFile(sourceFileName); - mockFileDataAccessor.AddFile(destFileName, mockFileDataAccessor.AdjustTimes(new MockFileData(sourceFile), TimeAdjustments.LastAccessTime), false); + throw new IOException("A file can not be created if it already exists."); } - return; - } - else - { - throw new IOException("A file can not be created if it already exists."); } - } - mockFileDataAccessor.RemoveFile(sourceFileName, false); - mockFileDataAccessor.AddFile(destFileName, mockFileDataAccessor.AdjustTimes(new MockFileData(sourceFile), TimeAdjustments.LastAccessTime), false); + mockFileDataAccessor.RemoveFile(sourceFileName, false); + mockFileDataAccessor.AddFile(destFileName, mockFileDataAccessor.AdjustTimes(new MockFileData(sourceFile), TimeAdjustments.LastAccessTime), false); + }); } #if FEATURE_FILE_MOVE_WITH_OVERWRITE @@ -668,16 +709,21 @@ private FileSystemStream OpenInternal( return CreateInternal(path, access, options); } - var mockFileData = mockFileDataAccessor.GetFile(path); - mockFileData.CheckFileAccess(path, access); - var timeAdjustments = TimeAdjustments.LastAccessTime; - if (access.HasFlag(FileAccess.Write)) + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + return mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.Open, ResourceType.File, () => { - timeAdjustments |= TimeAdjustments.LastWriteTime; - } - mockFileDataAccessor.AdjustTimes(mockFileData, timeAdjustments); + var mockFileData = mockFileDataAccessor.GetFile(path); + mockFileData.CheckFileAccess(path, access); + var timeAdjustments = TimeAdjustments.LastAccessTime; + if (access.HasFlag(FileAccess.Write)) + { + timeAdjustments |= TimeAdjustments.LastWriteTime; + } + mockFileDataAccessor.AdjustTimes(mockFileData, timeAdjustments); - return new MockFileStream(mockFileDataAccessor, path, mode, access, options); + return new MockFileStream(mockFileDataAccessor, path, mode, access, options); + }); } /// @@ -710,14 +756,19 @@ public override byte[] ReadAllBytes(string path) { mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(path, "path"); - if (!mockFileDataAccessor.FileExists(path)) + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + return mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.Read, ResourceType.File, () => { - throw CommonExceptions.FileNotFound(path); - } - mockFileDataAccessor.GetFile(path).CheckFileAccess(path, FileAccess.Read); - var fileData = mockFileDataAccessor.GetFile(path); - mockFileDataAccessor.AdjustTimes(fileData, TimeAdjustments.LastAccessTime); - return fileData.Contents.ToArray(); + if (!mockFileDataAccessor.FileExists(path)) + { + throw CommonExceptions.FileNotFound(path); + } + mockFileDataAccessor.GetFile(path).CheckFileAccess(path, FileAccess.Read); + var fileData = mockFileDataAccessor.GetFile(path); + mockFileDataAccessor.AdjustTimes(fileData, TimeAdjustments.LastAccessTime); + return fileData.Contents.ToArray(); + }); } /// @@ -725,17 +776,22 @@ public override string[] ReadAllLines(string path) { mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(path, "path"); - if (!mockFileDataAccessor.FileExists(path)) + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + return mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.Read, ResourceType.File, () => { - throw CommonExceptions.FileNotFound(path); - } - var fileData = mockFileDataAccessor.GetFile(path); - fileData.CheckFileAccess(path, FileAccess.Read); - mockFileDataAccessor.AdjustTimes(fileData, TimeAdjustments.LastAccessTime); - - return fileData - .TextContents - .SplitLines(); + if (!mockFileDataAccessor.FileExists(path)) + { + throw CommonExceptions.FileNotFound(path); + } + var fileData = mockFileDataAccessor.GetFile(path); + fileData.CheckFileAccess(path, FileAccess.Read); + mockFileDataAccessor.AdjustTimes(fileData, TimeAdjustments.LastAccessTime); + + return fileData + .TextContents + .SplitLines(); + }); } /// @@ -775,17 +831,22 @@ public override string ReadAllText(string path, Encoding encoding) { mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(path, "path"); - if (!mockFileDataAccessor.FileExists(path)) + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + return mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.Read, ResourceType.File, () => { - throw CommonExceptions.FileNotFound(path); - } + if (!mockFileDataAccessor.FileExists(path)) + { + throw CommonExceptions.FileNotFound(path); + } - if (encoding == null) - { - throw new ArgumentNullException(nameof(encoding)); - } + if (encoding == null) + { + throw new ArgumentNullException(nameof(encoding)); + } - return ReadAllTextInternal(path, encoding); + return ReadAllTextInternal(path, encoding); + }); } /// @@ -897,24 +958,29 @@ public override void SetAttributes(string path, FileAttributes fileAttributes) { mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(path, "path"); - var possibleFileData = mockFileDataAccessor.GetFile(path); - if (possibleFileData == null) + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.SetAttributes, ResourceType.File, () => { - var directoryInfo = mockFileDataAccessor.DirectoryInfo.New(path); - if (directoryInfo.Exists) + var possibleFileData = mockFileDataAccessor.GetFile(path); + if (possibleFileData == null) { - directoryInfo.Attributes = fileAttributes; + var directoryInfo = mockFileDataAccessor.DirectoryInfo.New(path); + if (directoryInfo.Exists) + { + directoryInfo.Attributes = fileAttributes; + } + else + { + throw CommonExceptions.FileNotFound(path); + } } else { - throw CommonExceptions.FileNotFound(path); + mockFileDataAccessor.AdjustTimes(possibleFileData, TimeAdjustments.LastAccessTime); + possibleFileData.Attributes = fileAttributes; } - } - else - { - mockFileDataAccessor.AdjustTimes(possibleFileData, TimeAdjustments.LastAccessTime); - possibleFileData.Attributes = fileAttributes; - } + }); } #if FEATURE_FILE_ATTRIBUTES_VIA_HANDLE @@ -930,7 +996,12 @@ public override void SetCreationTime(string path, DateTime creationTime) { mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(path, "path"); - mockFileDataAccessor.GetFile(path).CreationTime = new DateTimeOffset(creationTime); + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.SetTimes, ResourceType.File, () => + { + mockFileDataAccessor.GetFile(path).CreationTime = new DateTimeOffset(creationTime); + }); } #if FEATURE_FILE_ATTRIBUTES_VIA_HANDLE @@ -946,7 +1017,12 @@ public override void SetCreationTimeUtc(string path, DateTime creationTimeUtc) { mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(path, "path"); - mockFileDataAccessor.GetFile(path).CreationTime = new DateTimeOffset(creationTimeUtc, TimeSpan.Zero); + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.SetTimes, ResourceType.File, () => + { + mockFileDataAccessor.GetFile(path).CreationTime = new DateTimeOffset(creationTimeUtc, TimeSpan.Zero); + }); } #if FEATURE_FILE_ATTRIBUTES_VIA_HANDLE @@ -962,7 +1038,12 @@ public override void SetLastAccessTime(string path, DateTime lastAccessTime) { mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(path, "path"); - mockFileDataAccessor.GetFile(path).LastAccessTime = new DateTimeOffset(lastAccessTime); + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.SetTimes, ResourceType.File, () => + { + mockFileDataAccessor.GetFile(path).LastAccessTime = new DateTimeOffset(lastAccessTime); + }); } #if FEATURE_FILE_ATTRIBUTES_VIA_HANDLE @@ -978,7 +1059,12 @@ public override void SetLastAccessTimeUtc(string path, DateTime lastAccessTimeUt { mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(path, "path"); - mockFileDataAccessor.GetFile(path).LastAccessTime = new DateTimeOffset(lastAccessTimeUtc, TimeSpan.Zero); + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.SetTimes, ResourceType.File, () => + { + mockFileDataAccessor.GetFile(path).LastAccessTime = new DateTimeOffset(lastAccessTimeUtc, TimeSpan.Zero); + }); } #if FEATURE_FILE_ATTRIBUTES_VIA_HANDLE @@ -994,7 +1080,12 @@ public override void SetLastWriteTime(string path, DateTime lastWriteTime) { mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(path, "path"); - mockFileDataAccessor.GetFile(path).LastWriteTime = new DateTimeOffset(lastWriteTime); + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.SetTimes, ResourceType.File, () => + { + mockFileDataAccessor.GetFile(path).LastWriteTime = new DateTimeOffset(lastWriteTime); + }); } #if FEATURE_FILE_ATTRIBUTES_VIA_HANDLE @@ -1010,7 +1101,12 @@ public override void SetLastWriteTimeUtc(string path, DateTime lastWriteTimeUtc) { mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(path, "path"); - mockFileDataAccessor.GetFile(path).LastWriteTime = new DateTimeOffset(lastWriteTimeUtc, TimeSpan.Zero); + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.SetTimes, ResourceType.File, () => + { + mockFileDataAccessor.GetFile(path).LastWriteTime = new DateTimeOffset(lastWriteTimeUtc, TimeSpan.Zero); + }); } #if FEATURE_FILE_ATTRIBUTES_VIA_HANDLE @@ -1351,15 +1447,20 @@ public override void WriteAllText(string path, string contents, Encoding encodin mockFileDataAccessor.PathVerifier.IsLegalAbsoluteOrRelative(path, "path"); VerifyValueIsNotNull(path, "path"); - if (mockFileDataAccessor.Directory.Exists(path)) + var fullPath = mockFileDataAccessor.Path.GetFullPath(path); + + mockFileDataAccessor.Events.WithEvents(fullPath, FileOperation.Write, ResourceType.File, () => { - throw CommonExceptions.AccessDenied(path); - } + if (mockFileDataAccessor.Directory.Exists(path)) + { + throw CommonExceptions.AccessDenied(path); + } - VerifyDirectoryExists(path); + VerifyDirectoryExists(path); - MockFileData data = contents == null ? new MockFileData(new byte[0]) : new MockFileData(contents, encoding); - mockFileDataAccessor.AddFile(path, mockFileDataAccessor.AdjustTimes(data, TimeAdjustments.All)); + MockFileData data = contents == null ? new MockFileData(new byte[0]) : new MockFileData(contents, encoding); + mockFileDataAccessor.AddFile(path, mockFileDataAccessor.AdjustTimes(data, TimeAdjustments.All)); + }); } #if FEATURE_FILE_SPAN diff --git a/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileSystem.cs b/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileSystem.cs index 3f71206cb..b9ca71ae4 100644 --- a/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileSystem.cs +++ b/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileSystem.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Reflection; using System.Runtime.Serialization; +using System.IO.Abstractions.TestingHelpers.Events; namespace System.IO.Abstractions.TestingHelpers; @@ -19,6 +20,7 @@ public class MockFileSystem : FileSystemBase, IMockFileDataAccessor private readonly IDictionary files; private readonly IDictionary drives; private readonly PathVerifier pathVerifier; + private readonly MockFileSystemEvents events = new MockFileSystemEvents(); #if FEATURE_SERIALIZABLE [NonSerialized] #endif @@ -60,6 +62,11 @@ public MockFileSystem(IDictionary files, MockFileSystemOpt pathVerifier = new PathVerifier(this); this.files = new Dictionary(StringOperations.Comparer); drives = new Dictionary(StringOperations.Comparer); + + if (options.EnableEvents) + { + events.Enable(); + } Path = new MockPath(this, defaultTempDirectory); File = new MockFile(this); @@ -114,6 +121,11 @@ public MockFileSystem(IDictionary files, MockFileSystemOpt public IFileSystem FileSystem => this; /// public PathVerifier PathVerifier => pathVerifier; + + /// + /// Gets the event system for this MockFileSystem instance. + /// + public MockFileSystemEvents Events => events; /// /// Replaces the time provider with a mocked instance. This allows to influence the used time in tests. diff --git a/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileSystemOptions.cs b/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileSystemOptions.cs index f6f19dbf3..dddc46e24 100644 --- a/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileSystemOptions.cs +++ b/src/TestableIO.System.IO.Abstractions.TestingHelpers/MockFileSystemOptions.cs @@ -14,4 +14,10 @@ public class MockFileSystemOptions /// Flag indicating, if a temporary directory should be created. /// public bool CreateDefaultTempDir { get; init; } = true; + + /// + /// Flag indicating whether file system events should be enabled. + /// When false (default), the event system has zero overhead. + /// + public bool EnableEvents { get; init; } } \ No newline at end of file diff --git a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net472.txt b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net472.txt index 788ddb703..4996574ad 100644 --- a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net472.txt +++ b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net472.txt @@ -1,5 +1,57 @@ [assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/TestableIO/System.IO.Abstractions.git")] [assembly: System.Runtime.Versioning.TargetFramework(".NETFramework,Version=v4.7.2", FrameworkDisplayName=".NET Framework 4.7.2")] +namespace System.IO.Abstractions.TestingHelpers.Events +{ + public enum FileOperation + { + Create = 0, + Open = 1, + Write = 2, + Read = 3, + Delete = 4, + Move = 5, + Copy = 6, + SetAttributes = 7, + SetTimes = 8, + SetPermissions = 9, + } + public class FileSystemOperationEventArgs : System.EventArgs + { + public FileSystemOperationEventArgs(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.IO.Abstractions.TestingHelpers.Events.OperationPhase phase) { } + public System.IO.Abstractions.TestingHelpers.Events.FileOperation Operation { get; } + public string Path { get; } + public System.IO.Abstractions.TestingHelpers.Events.OperationPhase Phase { get; } + public System.IO.Abstractions.TestingHelpers.Events.ResourceType ResourceType { get; } + public void SetResponse(System.IO.Abstractions.TestingHelpers.Events.OperationResponse response) { } + } + [System.Serializable] + public class MockFileSystemEvents + { + public MockFileSystemEvents() { } + public bool IsEnabled { get; } + public System.IDisposable Subscribe(System.Action handler) { } + public System.IDisposable Subscribe(System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.Action handler) { } + public System.IDisposable Subscribe(System.IO.Abstractions.TestingHelpers.Events.FileOperation[] operations, System.Action handler) { } + public void WithEvents(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.Action action) { } + public T WithEvents(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.Func func) { } + } + public enum OperationPhase + { + Before = 0, + After = 1, + } + public class OperationResponse + { + public OperationResponse() { } + public bool Cancel { get; set; } + public System.Exception Exception { get; set; } + } + public enum ResourceType + { + File = 0, + Directory = 1, + } +} namespace System.IO.Abstractions.TestingHelpers { public interface IMockFileDataAccessor : System.IO.Abstractions.IFileSystem @@ -8,6 +60,7 @@ namespace System.IO.Abstractions.TestingHelpers System.Collections.Generic.IEnumerable AllDrives { get; } System.Collections.Generic.IEnumerable AllFiles { get; } System.Collections.Generic.IEnumerable AllPaths { get; } + System.IO.Abstractions.TestingHelpers.Events.MockFileSystemEvents Events { get; } System.IO.Abstractions.IFileSystem FileSystem { get; } System.IO.Abstractions.TestingHelpers.PathVerifier PathVerifier { get; } System.IO.Abstractions.TestingHelpers.StringOperations StringOperations { get; } @@ -346,6 +399,7 @@ namespace System.IO.Abstractions.TestingHelpers public override System.IO.Abstractions.IDirectory Directory { get; } public override System.IO.Abstractions.IDirectoryInfoFactory DirectoryInfo { get; } public override System.IO.Abstractions.IDriveInfoFactory DriveInfo { get; } + public System.IO.Abstractions.TestingHelpers.Events.MockFileSystemEvents Events { get; } public override System.IO.Abstractions.IFile File { get; } public override System.IO.Abstractions.IFileInfoFactory FileInfo { get; } public override System.IO.Abstractions.IFileStreamFactory FileStream { get; } @@ -378,6 +432,7 @@ namespace System.IO.Abstractions.TestingHelpers public MockFileSystemOptions() { } public bool CreateDefaultTempDir { get; init; } public string CurrentDirectory { get; init; } + public bool EnableEvents { get; init; } } [System.Serializable] public class MockFileSystemWatcherFactory : System.IO.Abstractions.IFileSystemEntity, System.IO.Abstractions.IFileSystemWatcherFactory diff --git a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net6.0.txt b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net6.0.txt index afd2ce3c4..7f6fa3188 100644 --- a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net6.0.txt +++ b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net6.0.txt @@ -1,5 +1,57 @@ [assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/TestableIO/System.IO.Abstractions.git")] [assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v6.0", FrameworkDisplayName=".NET 6.0")] +namespace System.IO.Abstractions.TestingHelpers.Events +{ + public enum FileOperation + { + Create = 0, + Open = 1, + Write = 2, + Read = 3, + Delete = 4, + Move = 5, + Copy = 6, + SetAttributes = 7, + SetTimes = 8, + SetPermissions = 9, + } + public class FileSystemOperationEventArgs : System.EventArgs + { + public FileSystemOperationEventArgs(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.IO.Abstractions.TestingHelpers.Events.OperationPhase phase) { } + public System.IO.Abstractions.TestingHelpers.Events.FileOperation Operation { get; } + public string Path { get; } + public System.IO.Abstractions.TestingHelpers.Events.OperationPhase Phase { get; } + public System.IO.Abstractions.TestingHelpers.Events.ResourceType ResourceType { get; } + public void SetResponse(System.IO.Abstractions.TestingHelpers.Events.OperationResponse response) { } + } + [System.Serializable] + public class MockFileSystemEvents + { + public MockFileSystemEvents() { } + public bool IsEnabled { get; } + public System.IDisposable Subscribe(System.Action handler) { } + public System.IDisposable Subscribe(System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.Action handler) { } + public System.IDisposable Subscribe(System.IO.Abstractions.TestingHelpers.Events.FileOperation[] operations, System.Action handler) { } + public void WithEvents(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.Action action) { } + public T WithEvents(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.Func func) { } + } + public enum OperationPhase + { + Before = 0, + After = 1, + } + public class OperationResponse + { + public OperationResponse() { } + public bool Cancel { get; set; } + public System.Exception Exception { get; set; } + } + public enum ResourceType + { + File = 0, + Directory = 1, + } +} namespace System.IO.Abstractions.TestingHelpers { public interface IMockFileDataAccessor : System.IO.Abstractions.IFileSystem @@ -8,6 +60,7 @@ namespace System.IO.Abstractions.TestingHelpers System.Collections.Generic.IEnumerable AllDrives { get; } System.Collections.Generic.IEnumerable AllFiles { get; } System.Collections.Generic.IEnumerable AllPaths { get; } + System.IO.Abstractions.TestingHelpers.Events.MockFileSystemEvents Events { get; } System.IO.Abstractions.IFileSystem FileSystem { get; } System.IO.Abstractions.TestingHelpers.PathVerifier PathVerifier { get; } System.IO.Abstractions.TestingHelpers.StringOperations StringOperations { get; } @@ -401,6 +454,7 @@ namespace System.IO.Abstractions.TestingHelpers public override System.IO.Abstractions.IDirectory Directory { get; } public override System.IO.Abstractions.IDirectoryInfoFactory DirectoryInfo { get; } public override System.IO.Abstractions.IDriveInfoFactory DriveInfo { get; } + public System.IO.Abstractions.TestingHelpers.Events.MockFileSystemEvents Events { get; } public override System.IO.Abstractions.IFile File { get; } public override System.IO.Abstractions.IFileInfoFactory FileInfo { get; } public override System.IO.Abstractions.IFileStreamFactory FileStream { get; } @@ -433,6 +487,7 @@ namespace System.IO.Abstractions.TestingHelpers public MockFileSystemOptions() { } public bool CreateDefaultTempDir { get; init; } public string CurrentDirectory { get; init; } + public bool EnableEvents { get; init; } } [System.Serializable] public class MockFileSystemWatcherFactory : System.IO.Abstractions.IFileSystemEntity, System.IO.Abstractions.IFileSystemWatcherFactory diff --git a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net8.0.txt b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net8.0.txt index 1c581cab8..19b003596 100644 --- a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net8.0.txt +++ b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net8.0.txt @@ -1,5 +1,57 @@ [assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/TestableIO/System.IO.Abstractions.git")] [assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v8.0", FrameworkDisplayName=".NET 8.0")] +namespace System.IO.Abstractions.TestingHelpers.Events +{ + public enum FileOperation + { + Create = 0, + Open = 1, + Write = 2, + Read = 3, + Delete = 4, + Move = 5, + Copy = 6, + SetAttributes = 7, + SetTimes = 8, + SetPermissions = 9, + } + public class FileSystemOperationEventArgs : System.EventArgs + { + public FileSystemOperationEventArgs(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.IO.Abstractions.TestingHelpers.Events.OperationPhase phase) { } + public System.IO.Abstractions.TestingHelpers.Events.FileOperation Operation { get; } + public string Path { get; } + public System.IO.Abstractions.TestingHelpers.Events.OperationPhase Phase { get; } + public System.IO.Abstractions.TestingHelpers.Events.ResourceType ResourceType { get; } + public void SetResponse(System.IO.Abstractions.TestingHelpers.Events.OperationResponse response) { } + } + [System.Serializable] + public class MockFileSystemEvents + { + public MockFileSystemEvents() { } + public bool IsEnabled { get; } + public System.IDisposable Subscribe(System.Action handler) { } + public System.IDisposable Subscribe(System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.Action handler) { } + public System.IDisposable Subscribe(System.IO.Abstractions.TestingHelpers.Events.FileOperation[] operations, System.Action handler) { } + public void WithEvents(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.Action action) { } + public T WithEvents(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.Func func) { } + } + public enum OperationPhase + { + Before = 0, + After = 1, + } + public class OperationResponse + { + public OperationResponse() { } + public bool Cancel { get; set; } + public System.Exception Exception { get; set; } + } + public enum ResourceType + { + File = 0, + Directory = 1, + } +} namespace System.IO.Abstractions.TestingHelpers { public interface IMockFileDataAccessor : System.IO.Abstractions.IFileSystem @@ -8,6 +60,7 @@ namespace System.IO.Abstractions.TestingHelpers System.Collections.Generic.IEnumerable AllDrives { get; } System.Collections.Generic.IEnumerable AllFiles { get; } System.Collections.Generic.IEnumerable AllPaths { get; } + System.IO.Abstractions.TestingHelpers.Events.MockFileSystemEvents Events { get; } System.IO.Abstractions.IFileSystem FileSystem { get; } System.IO.Abstractions.TestingHelpers.PathVerifier PathVerifier { get; } System.IO.Abstractions.TestingHelpers.StringOperations StringOperations { get; } @@ -425,6 +478,7 @@ namespace System.IO.Abstractions.TestingHelpers public override System.IO.Abstractions.IDirectory Directory { get; } public override System.IO.Abstractions.IDirectoryInfoFactory DirectoryInfo { get; } public override System.IO.Abstractions.IDriveInfoFactory DriveInfo { get; } + public System.IO.Abstractions.TestingHelpers.Events.MockFileSystemEvents Events { get; } public override System.IO.Abstractions.IFile File { get; } public override System.IO.Abstractions.IFileInfoFactory FileInfo { get; } public override System.IO.Abstractions.IFileStreamFactory FileStream { get; } @@ -457,6 +511,7 @@ namespace System.IO.Abstractions.TestingHelpers public MockFileSystemOptions() { } public bool CreateDefaultTempDir { get; init; } public string CurrentDirectory { get; init; } + public bool EnableEvents { get; init; } } [System.Serializable] public class MockFileSystemWatcherFactory : System.IO.Abstractions.IFileSystemEntity, System.IO.Abstractions.IFileSystemWatcherFactory diff --git a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net9.0.txt b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net9.0.txt index 6c78cf677..849495549 100644 --- a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net9.0.txt +++ b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_net9.0.txt @@ -1,5 +1,57 @@ [assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/TestableIO/System.IO.Abstractions.git")] [assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v9.0", FrameworkDisplayName=".NET 9.0")] +namespace System.IO.Abstractions.TestingHelpers.Events +{ + public enum FileOperation + { + Create = 0, + Open = 1, + Write = 2, + Read = 3, + Delete = 4, + Move = 5, + Copy = 6, + SetAttributes = 7, + SetTimes = 8, + SetPermissions = 9, + } + public class FileSystemOperationEventArgs : System.EventArgs + { + public FileSystemOperationEventArgs(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.IO.Abstractions.TestingHelpers.Events.OperationPhase phase) { } + public System.IO.Abstractions.TestingHelpers.Events.FileOperation Operation { get; } + public string Path { get; } + public System.IO.Abstractions.TestingHelpers.Events.OperationPhase Phase { get; } + public System.IO.Abstractions.TestingHelpers.Events.ResourceType ResourceType { get; } + public void SetResponse(System.IO.Abstractions.TestingHelpers.Events.OperationResponse response) { } + } + [System.Serializable] + public class MockFileSystemEvents + { + public MockFileSystemEvents() { } + public bool IsEnabled { get; } + public System.IDisposable Subscribe(System.Action handler) { } + public System.IDisposable Subscribe(System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.Action handler) { } + public System.IDisposable Subscribe(System.IO.Abstractions.TestingHelpers.Events.FileOperation[] operations, System.Action handler) { } + public void WithEvents(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.Action action) { } + public T WithEvents(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.Func func) { } + } + public enum OperationPhase + { + Before = 0, + After = 1, + } + public class OperationResponse + { + public OperationResponse() { } + public bool Cancel { get; set; } + public System.Exception Exception { get; set; } + } + public enum ResourceType + { + File = 0, + Directory = 1, + } +} namespace System.IO.Abstractions.TestingHelpers { public interface IMockFileDataAccessor : System.IO.Abstractions.IFileSystem @@ -8,6 +60,7 @@ namespace System.IO.Abstractions.TestingHelpers System.Collections.Generic.IEnumerable AllDrives { get; } System.Collections.Generic.IEnumerable AllFiles { get; } System.Collections.Generic.IEnumerable AllPaths { get; } + System.IO.Abstractions.TestingHelpers.Events.MockFileSystemEvents Events { get; } System.IO.Abstractions.IFileSystem FileSystem { get; } System.IO.Abstractions.TestingHelpers.PathVerifier PathVerifier { get; } System.IO.Abstractions.TestingHelpers.StringOperations StringOperations { get; } @@ -439,6 +492,7 @@ namespace System.IO.Abstractions.TestingHelpers public override System.IO.Abstractions.IDirectory Directory { get; } public override System.IO.Abstractions.IDirectoryInfoFactory DirectoryInfo { get; } public override System.IO.Abstractions.IDriveInfoFactory DriveInfo { get; } + public System.IO.Abstractions.TestingHelpers.Events.MockFileSystemEvents Events { get; } public override System.IO.Abstractions.IFile File { get; } public override System.IO.Abstractions.IFileInfoFactory FileInfo { get; } public override System.IO.Abstractions.IFileStreamFactory FileStream { get; } @@ -471,6 +525,7 @@ namespace System.IO.Abstractions.TestingHelpers public MockFileSystemOptions() { } public bool CreateDefaultTempDir { get; init; } public string CurrentDirectory { get; init; } + public bool EnableEvents { get; init; } } [System.Serializable] public class MockFileSystemWatcherFactory : System.IO.Abstractions.IFileSystemEntity, System.IO.Abstractions.IFileSystemWatcherFactory diff --git a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.0.txt b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.0.txt index e0880b9f8..1873586b5 100644 --- a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.0.txt +++ b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.0.txt @@ -1,5 +1,57 @@ [assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/TestableIO/System.IO.Abstractions.git")] [assembly: System.Runtime.Versioning.TargetFramework(".NETStandard,Version=v2.0", FrameworkDisplayName=".NET Standard 2.0")] +namespace System.IO.Abstractions.TestingHelpers.Events +{ + public enum FileOperation + { + Create = 0, + Open = 1, + Write = 2, + Read = 3, + Delete = 4, + Move = 5, + Copy = 6, + SetAttributes = 7, + SetTimes = 8, + SetPermissions = 9, + } + public class FileSystemOperationEventArgs : System.EventArgs + { + public FileSystemOperationEventArgs(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.IO.Abstractions.TestingHelpers.Events.OperationPhase phase) { } + public System.IO.Abstractions.TestingHelpers.Events.FileOperation Operation { get; } + public string Path { get; } + public System.IO.Abstractions.TestingHelpers.Events.OperationPhase Phase { get; } + public System.IO.Abstractions.TestingHelpers.Events.ResourceType ResourceType { get; } + public void SetResponse(System.IO.Abstractions.TestingHelpers.Events.OperationResponse response) { } + } + [System.Serializable] + public class MockFileSystemEvents + { + public MockFileSystemEvents() { } + public bool IsEnabled { get; } + public System.IDisposable Subscribe(System.Action handler) { } + public System.IDisposable Subscribe(System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.Action handler) { } + public System.IDisposable Subscribe(System.IO.Abstractions.TestingHelpers.Events.FileOperation[] operations, System.Action handler) { } + public void WithEvents(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.Action action) { } + public T WithEvents(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.Func func) { } + } + public enum OperationPhase + { + Before = 0, + After = 1, + } + public class OperationResponse + { + public OperationResponse() { } + public bool Cancel { get; set; } + public System.Exception Exception { get; set; } + } + public enum ResourceType + { + File = 0, + Directory = 1, + } +} namespace System.IO.Abstractions.TestingHelpers { public interface IMockFileDataAccessor : System.IO.Abstractions.IFileSystem @@ -8,6 +60,7 @@ namespace System.IO.Abstractions.TestingHelpers System.Collections.Generic.IEnumerable AllDrives { get; } System.Collections.Generic.IEnumerable AllFiles { get; } System.Collections.Generic.IEnumerable AllPaths { get; } + System.IO.Abstractions.TestingHelpers.Events.MockFileSystemEvents Events { get; } System.IO.Abstractions.IFileSystem FileSystem { get; } System.IO.Abstractions.TestingHelpers.PathVerifier PathVerifier { get; } System.IO.Abstractions.TestingHelpers.StringOperations StringOperations { get; } @@ -346,6 +399,7 @@ namespace System.IO.Abstractions.TestingHelpers public override System.IO.Abstractions.IDirectory Directory { get; } public override System.IO.Abstractions.IDirectoryInfoFactory DirectoryInfo { get; } public override System.IO.Abstractions.IDriveInfoFactory DriveInfo { get; } + public System.IO.Abstractions.TestingHelpers.Events.MockFileSystemEvents Events { get; } public override System.IO.Abstractions.IFile File { get; } public override System.IO.Abstractions.IFileInfoFactory FileInfo { get; } public override System.IO.Abstractions.IFileStreamFactory FileStream { get; } @@ -378,6 +432,7 @@ namespace System.IO.Abstractions.TestingHelpers public MockFileSystemOptions() { } public bool CreateDefaultTempDir { get; init; } public string CurrentDirectory { get; init; } + public bool EnableEvents { get; init; } } [System.Serializable] public class MockFileSystemWatcherFactory : System.IO.Abstractions.IFileSystemEntity, System.IO.Abstractions.IFileSystemWatcherFactory diff --git a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.1.txt b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.1.txt index 80f876c0c..198416d7b 100644 --- a/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.1.txt +++ b/tests/TestableIO.System.IO.Abstractions.Api.Tests/Expected/TestableIO.System.IO.Abstractions.TestingHelpers_netstandard2.1.txt @@ -1,5 +1,57 @@ [assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/TestableIO/System.IO.Abstractions.git")] [assembly: System.Runtime.Versioning.TargetFramework(".NETStandard,Version=v2.1", FrameworkDisplayName=".NET Standard 2.1")] +namespace System.IO.Abstractions.TestingHelpers.Events +{ + public enum FileOperation + { + Create = 0, + Open = 1, + Write = 2, + Read = 3, + Delete = 4, + Move = 5, + Copy = 6, + SetAttributes = 7, + SetTimes = 8, + SetPermissions = 9, + } + public class FileSystemOperationEventArgs : System.EventArgs + { + public FileSystemOperationEventArgs(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.IO.Abstractions.TestingHelpers.Events.OperationPhase phase) { } + public System.IO.Abstractions.TestingHelpers.Events.FileOperation Operation { get; } + public string Path { get; } + public System.IO.Abstractions.TestingHelpers.Events.OperationPhase Phase { get; } + public System.IO.Abstractions.TestingHelpers.Events.ResourceType ResourceType { get; } + public void SetResponse(System.IO.Abstractions.TestingHelpers.Events.OperationResponse response) { } + } + [System.Serializable] + public class MockFileSystemEvents + { + public MockFileSystemEvents() { } + public bool IsEnabled { get; } + public System.IDisposable Subscribe(System.Action handler) { } + public System.IDisposable Subscribe(System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.Action handler) { } + public System.IDisposable Subscribe(System.IO.Abstractions.TestingHelpers.Events.FileOperation[] operations, System.Action handler) { } + public void WithEvents(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.Action action) { } + public T WithEvents(string path, System.IO.Abstractions.TestingHelpers.Events.FileOperation operation, System.IO.Abstractions.TestingHelpers.Events.ResourceType resourceType, System.Func func) { } + } + public enum OperationPhase + { + Before = 0, + After = 1, + } + public class OperationResponse + { + public OperationResponse() { } + public bool Cancel { get; set; } + public System.Exception Exception { get; set; } + } + public enum ResourceType + { + File = 0, + Directory = 1, + } +} namespace System.IO.Abstractions.TestingHelpers { public interface IMockFileDataAccessor : System.IO.Abstractions.IFileSystem @@ -8,6 +60,7 @@ namespace System.IO.Abstractions.TestingHelpers System.Collections.Generic.IEnumerable AllDrives { get; } System.Collections.Generic.IEnumerable AllFiles { get; } System.Collections.Generic.IEnumerable AllPaths { get; } + System.IO.Abstractions.TestingHelpers.Events.MockFileSystemEvents Events { get; } System.IO.Abstractions.IFileSystem FileSystem { get; } System.IO.Abstractions.TestingHelpers.PathVerifier PathVerifier { get; } System.IO.Abstractions.TestingHelpers.StringOperations StringOperations { get; } @@ -374,6 +427,7 @@ namespace System.IO.Abstractions.TestingHelpers public override System.IO.Abstractions.IDirectory Directory { get; } public override System.IO.Abstractions.IDirectoryInfoFactory DirectoryInfo { get; } public override System.IO.Abstractions.IDriveInfoFactory DriveInfo { get; } + public System.IO.Abstractions.TestingHelpers.Events.MockFileSystemEvents Events { get; } public override System.IO.Abstractions.IFile File { get; } public override System.IO.Abstractions.IFileInfoFactory FileInfo { get; } public override System.IO.Abstractions.IFileStreamFactory FileStream { get; } @@ -406,6 +460,7 @@ namespace System.IO.Abstractions.TestingHelpers public MockFileSystemOptions() { } public bool CreateDefaultTempDir { get; init; } public string CurrentDirectory { get; init; } + public bool EnableEvents { get; init; } } [System.Serializable] public class MockFileSystemWatcherFactory : System.IO.Abstractions.IFileSystemEntity, System.IO.Abstractions.IFileSystemWatcherFactory diff --git a/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileSystemCreativeEventsTests.cs b/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileSystemCreativeEventsTests.cs new file mode 100644 index 000000000..4207da68a --- /dev/null +++ b/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileSystemCreativeEventsTests.cs @@ -0,0 +1,845 @@ +using System.Collections.Generic; +using System.Linq; +using System.IO.Abstractions.TestingHelpers.Events; + +namespace System.IO.Abstractions.TestingHelpers.Tests; + +using XFS = MockUnixSupport; + +/// +/// Contains tests for verifying various advanced event-driven behaviors in a mocked file system. +/// These tests explore complex interactions, such as event tracking, validation mechanisms, +/// milestone progression, and performance profiling within the MockFileSystem framework. +/// +public class MockFileSystemCreativeEventsTests +{ + /// + /// Indicates whether test output should be enabled during test execution. + /// When set to true, test logs and other output are written for + /// debugging or analysis purposes. The value is determined by the + /// environment variable "MOCK_FS_TEST_OUTPUT", and is true if + /// the variable is set to "1". + /// + private static readonly bool EnableTestOutput = Environment.GetEnvironmentVariable("MOCK_FS_TEST_OUTPUT") == "1"; + + /// + /// Writes a test output message if test output is enabled. + /// + /// The message to write to the test output. + private static void WriteTestOutput(string message) + { + if (EnableTestOutput) + { + TestContext.Out.WriteLine(message); + } + } + + /// + /// Verifies that the file system correctly tracks file operations + /// and generates an operation log when events are enabled. + /// + /// + /// This test ensures that when the is configured + /// with event tracking enabled, operations such as file creation, modification, + /// deletion, and other file system activities are logged correctly. The test + /// validates that the operation log captures the expected sequence and details + /// of events for each file system activity performed. + /// + /// + /// Thrown if the expected number of operations or their details are not present in the operation log. + /// + [Test] + public void Events_FileSystemOperationTracker_ShouldCreateOperationLog() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var operationLog = new List(); + + using (fileSystem.Events.Subscribe(args => + { + if (args.Phase != OperationPhase.After) + { + return; + } + var logEntry = args.Operation switch + { + FileOperation.Create => $"Created new file at {args.Path}", + FileOperation.Write => $"Modified content of {args.Path}", + FileOperation.Read => $"Accessed data from {args.Path}", + FileOperation.Copy => $"Duplicated file to {args.Path}", + FileOperation.Move => $"Relocated file to {args.Path}", + FileOperation.Delete => $"Removed file at {args.Path}", + _ => $"Performed {args.Operation} on {args.Path}" + }; + operationLog.Add(logEntry); + })) + { + var testFile = XFS.Path(@"C:\test-file.txt"); + var backupFile = XFS.Path(@"C:\test-backup.txt"); + + fileSystem.File.Create(testFile).Dispose(); + fileSystem.File.WriteAllText(testFile, "test content"); + fileSystem.File.ReadAllText(testFile); + fileSystem.File.Copy(testFile, backupFile); + fileSystem.File.Move(backupFile, XFS.Path(@"C:\final-backup.txt")); + fileSystem.File.Delete(testFile); + } + + WriteTestOutput("Operation Log:"); + foreach (var entry in operationLog) + { + WriteTestOutput($" {entry}"); + } + + using (Assert.EnterMultipleScope()) + { + // Order: Open, Create, Write, Read, Copy, Move, Delete + Assert.That(operationLog, Has.Count.EqualTo(7)); // Create fires 2 events (Create+Open), Write, Read, Copy, Move, Delete each fire 1 + Assert.That(operationLog.Any(l => l.Contains("Created new file")), Is.True); + Assert.That(operationLog.Any(l => l.Contains("Modified content")), Is.True); + Assert.That(operationLog.Any(l => l.Contains("Accessed data")), Is.True); + Assert.That(operationLog.Any(l => l.Contains("Duplicated file")), Is.True); + Assert.That(operationLog.Any(l => l.Contains("Relocated file")), Is.True); + Assert.That(operationLog.Any(l => l.Contains("Removed file")), Is.True); + } + } + + /// + /// Validates the tracking of experience points and associated operations using a mock file system + /// with enabled event subscription. + /// + /// + /// This test verifies that file system operations like creating, reading, copying, moving, and deleting files + /// are appropriately recorded to update player-related statistics such as level, experience points, performed operations, + /// and milestones achieved. + /// The test subscribes to file system events and reacts to specific phases of those events to calculate + /// accumulated experience points and milestones. It ensures the tracking logic adheres to defined criteria + /// while performing a series of file-based operations. + /// + /// + /// Thrown when the expected outcomes for level, experience points, milestones, or operations are not met. + /// + [Test] + public void Events_FileSystemProgressTracker_ShouldTrackExperiencePoints() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var playerStats = new Dictionary + { + ["level"] = 1, + ["experience"] = 0, + ["operations_performed"] = 0, + ["milestones"] = new List() + }; + + using (fileSystem.Events.Subscribe(args => + { + if (args.Phase == OperationPhase.After) + { + var pointsGained = args.Operation switch + { + FileOperation.Create => 10, + FileOperation.Read => 2, + FileOperation.Write => 5, + FileOperation.Copy => 8, + FileOperation.Move => 12, + FileOperation.Delete => 15, + FileOperation.SetAttributes => 7, + FileOperation.SetTimes => 3, + _ => 1 + }; + + playerStats["experience"] = (int)playerStats["experience"] + pointsGained; + playerStats["operations_performed"] = (int)playerStats["operations_performed"] + 1; + + var milestones = (List)playerStats["milestones"]; + + // Level progression system + var currentExp = (int)playerStats["experience"]; + var newLevel = 1 + (currentExp / 50); + if (newLevel > (int)playerStats["level"]) + { + playerStats["level"] = newLevel; + milestones.Add($"Advanced to level {newLevel}"); + } + + // Milestone tracking + var opsPerformed = (int)playerStats["operations_performed"]; + if (opsPerformed == 10 && !milestones.Contains("Completed 10 operations")) + { + milestones.Add("Completed 10 operations"); + } + + if (args.Operation == FileOperation.Delete && !milestones.Contains("First file deletion")) + { + milestones.Add("First file deletion"); + } + + if (args.Path.EndsWith(".exe") && !milestones.Contains("Handled executable file")) + { + milestones.Add("Handled executable file"); + } + } + })) + { + var testFiles = new[] + { + XFS.Path(@"C:\document.txt"), + XFS.Path(@"C:\code.cs"), + XFS.Path(@"C:\program.exe"), + XFS.Path(@"C:\backup.txt") + }; + + // Create files + foreach (var file in testFiles.Take(3)) + { + fileSystem.File.Create(file).Dispose(); + fileSystem.File.WriteAllText(file, "test content"); + } + + // Read content + fileSystem.File.ReadAllText(testFiles[1]); + + // Create backup + fileSystem.File.Copy(testFiles[0], testFiles[3]); + + // Create archive directory and move file + fileSystem.Directory.CreateDirectory(XFS.Path(@"C:\archive")); + fileSystem.File.Move(testFiles[1], XFS.Path(@"C:\archive\code.cs")); + + // Set file attributes + fileSystem.File.SetAttributes(testFiles[2], FileAttributes.Hidden | FileAttributes.System); + + // Clean up executable + fileSystem.File.Delete(testFiles[2]); + } + + WriteTestOutput("Progress Tracker Stats:"); + WriteTestOutput($"Level: {playerStats["level"]}"); + WriteTestOutput($"Experience: {playerStats["experience"]}"); + WriteTestOutput($"Operations: {playerStats["operations_performed"]}"); + WriteTestOutput("Milestones:"); + foreach (var milestone in (List)playerStats["milestones"]) + { + WriteTestOutput($" {milestone}"); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That((int)playerStats["level"], Is.GreaterThan(1)); + Assert.That((int)playerStats["experience"], Is.GreaterThan(100)); + Assert.That(((List)playerStats["milestones"]).Count, Is.GreaterThan(0)); + } + } + + /// + /// Tests that the timestamp modification operations on a mocked file system are intercepted and validated + /// according to specified conditions when events are enabled via . + /// + /// + /// This test ensures the following: + /// - Timestamps on files can be successfully updated to future dates. + /// - Timestamps involving invalid or past dates will result in thrown exceptions. + /// It verifies that the subscription to the file system events captures and processes the operations accordingly, + /// with appropriate logging of the detected time modifications. + /// + /// + /// Thrown when attempting to set a file timestamp to a past or otherwise invalid date. + /// + /// + /// Designed to test custom validation logic tied to file timestamp operations. + /// + [Test] + public void Events_TimeStampValidator_ShouldInterceptSetTimesOperations() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var timeValidationLog = new List(); + var originalTime = DateTime.Now; + var futureTime = originalTime.AddDays(365); + + using (fileSystem.Events.Subscribe(FileOperation.SetTimes, args => + { + if (args.Phase == OperationPhase.Before) + { + timeValidationLog.Add($"Time modification detected for {args.Path}"); + + // Implement validation rules + if (args.Path.Contains("future")) + { + timeValidationLog.Add("Processing future timestamp"); + } + else if (args.Path.Contains("past")) + { + // Prevent setting dates too far in the past + timeValidationLog.Add("Rejecting past timestamp modification"); + args.SetResponse(new OperationResponse + { + Exception = new ArgumentException("Cannot set timestamps to dates before system epoch") + }); + } + } + })) + { + var futureFile = XFS.Path(@"C:\future-document.txt"); + var pastFile = XFS.Path(@"C:\past-document.txt"); + + fileSystem.File.Create(futureFile).Dispose(); + fileSystem.File.Create(pastFile).Dispose(); + + // This should succeed + fileSystem.File.SetCreationTime(futureFile, futureTime); + + // This should fail + Assert.Throws(() => + fileSystem.File.SetCreationTime(pastFile, new DateTime(1970, 1, 1))); + } + + WriteTestOutput("Time Validation Log:"); + foreach (var entry in timeValidationLog) + { + WriteTestOutput($" {entry}"); + } + + Assert.That(timeValidationLog, Has.Count.EqualTo(4)); + using (Assert.EnterMultipleScope()) + { + Assert.That(timeValidationLog[0], Does.Contain("Time modification detected")); + Assert.That(timeValidationLog[1], Does.Contain("Processing future timestamp")); + Assert.That(timeValidationLog[2], Does.Contain("Time modification detected")); + Assert.That(timeValidationLog[3], Does.Contain("Rejecting past timestamp")); + } + } + + /// + /// Verifies that the file system implements the superposition pattern for quantum files + /// by simulating quantum behavior where files exist in multiple states until observed. + /// + /// + /// This test ensures that quantum files behave according to the superposition principle, + /// collapsing their state upon file read operations. Regular files are unaffected and + /// behave deterministically. It utilizes mocked file system events to track operations + /// and verify expected outcomes under controlled scenarios with deterministic behavior. + /// + [Test] + public void Events_QuantumFileSystem_ShouldImplementSuperpositionPattern() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var observationLog = new List(); + var quantumStates = new Dictionary(); // true = exists, false = doesn't exist + var deterministicRandom = new Random(42); // Fixed seed for deterministic behavior + + var quantumFiles = new[] + { + XFS.Path(@"C:\quantum-file-1.txt"), + XFS.Path(@"C:\quantum-file-2.txt"), + XFS.Path(@"C:\quantum-file-3.txt"), + XFS.Path(@"C:\regular-file.txt") + }; + + var observationResults = new List(); + + using (fileSystem.Events.Subscribe(args => + { + if (args.Phase != OperationPhase.Before || args.Operation != FileOperation.Read) + { + return; + } + // Quantum files exist in superposition until observed (read) + if (!args.Path.Contains("quantum") || quantumStates.ContainsKey(args.Path)) + { + return; + } + // Collapse the wave function deterministically + var exists = deterministicRandom.NextDouble() > 0.5; + quantumStates[args.Path] = exists; + + observationLog.Add($"Quantum state collapsed for {args.Path}: {(exists ? "EXISTS" : "DOES_NOT_EXIST")}"); + + if (!exists) + { + args.SetResponse(new OperationResponse + { + Exception = new FileNotFoundException("Quantum file collapsed to non-existence state") + }); + } + })) + { + // Create all files initially + foreach (var file in quantumFiles) + { + fileSystem.File.Create(file).Dispose(); + fileSystem.File.WriteAllText(file, "quantum content"); + } + + // Observe quantum files (this collapses their wave functions) + foreach (var quantumFile in quantumFiles.Where(f => f.Contains("quantum"))) + { + try + { + fileSystem.File.ReadAllText(quantumFile); + observationResults.Add(true); // File existed after observation + } + catch (FileNotFoundException) + { + observationResults.Add(false); // File didn't exist after observation + } + } + + // Regular file should always be readable + var regularContent = fileSystem.File.ReadAllText(quantumFiles[3]); + Assert.That(regularContent, Is.EqualTo("quantum content")); + + WriteTestOutput("Quantum Observation Log:"); + foreach (var entry in observationLog) + { + WriteTestOutput($" {entry}"); + } + + using (Assert.EnterMultipleScope()) + { + + // With seed 42, we expect deterministic results + Assert.That(observationLog, Has.Count.EqualTo(3)); + Assert.That(observationResults, Has.Count.EqualTo(3)); + + // Verify quantum collapse occurred (exact results depend on Random implementation) + Assert.That(quantumStates, Has.Count.EqualTo(3)); // All three quantum files observed + } + using (Assert.EnterMultipleScope()) + { + Assert.That(quantumStates.ContainsKey(quantumFiles[0]), Is.True); + Assert.That(quantumStates.ContainsKey(quantumFiles[1]), Is.True); + Assert.That(quantumStates.ContainsKey(quantumFiles[2]), Is.True); + } + } + } + + /// + /// Tests the functionality of the mock file system's auto-correction feature for detecting and suggesting typo corrections + /// in file names when new files are created. The test subscribes to file system events and identifies potential typos + /// by comparing file names to a predefined set of known good names. + /// + /// + /// This test verifies the ability of the file system to detect and suggest corrections for typos in file names during + /// file creation. The mock file system is configured to enable events, and corrections are logged and asserted + /// against expected results. Only typos in file names associated with the "Before" phase of file creation operations + /// are addressed by this test. + /// + /// + /// Thrown when the detected correction suggestions do not match the expected results. + /// + [Test] + public void Events_AutoCorrectFileSystem_ShouldDetectAndSuggestTypoCorrections() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var correctionLog = new List(); + var knownGoodNames = new HashSet { "document", "report", "config", "readme", "license" }; + + using (fileSystem.Events.Subscribe(args => + { + if (args.Phase != OperationPhase.Before || args.Operation != FileOperation.Create) + { + return; + } + var fileName = fileSystem.Path.GetFileNameWithoutExtension(args.Path); + + // Check for potential typos + foreach (var goodName in knownGoodNames.Where(goodName => IsLikelyTypo(fileName, goodName))) + { + correctionLog.Add($"Potential typo detected: '{fileName}' might be '{goodName}'"); + break; + } + })) + { + var testFiles = new[] + { + XFS.Path(@"C:\documnet.txt"), // typo: document + XFS.Path(@"C:\reprot.txt"), // typo: report + XFS.Path(@"C:\cofig.txt"), // typo: config + XFS.Path(@"C:\readme.txt"), // correct + XFS.Path(@"C:\licnese.txt") // typo: license + }; + + foreach (var file in testFiles) + { + fileSystem.File.Create(file).Dispose(); + fileSystem.File.WriteAllText(file, "content"); + } + } + + WriteTestOutput("Auto-Correct Suggestions:"); + foreach (var entry in correctionLog) + { + WriteTestOutput($" {entry}"); + } + + Assert.That(correctionLog, Has.Count.EqualTo(4)); // 4 typos detected + using (Assert.EnterMultipleScope()) + { + Assert.That(correctionLog[0], Does.Contain("documnet").And.Contain("document")); + Assert.That(correctionLog[1], Does.Contain("reprot").And.Contain("report")); + Assert.That(correctionLog[2], Does.Contain("cofig").And.Contain("config")); + Assert.That(correctionLog[3], Does.Contain("licnese").And.Contain("license")); + } + } + + /// + /// Validates the reliability of the file system by simulating controlled failure scenarios and verifying proper handling. + /// + /// + /// This method uses a deterministic random seed to simulate a predictable sequence of failures during file system operations. + /// It tracks success and failure events while ensuring the controlled pattern of failures is correctly maintained during testing. + /// The method also verifies that failure and success events have occurred as expected. + /// + /// + /// Thrown if the number of controlled failures or successes does not meet the expected conditions. + /// + [Test] + public void Events_ReliabilityTestingSystem_ShouldSimulateControlledFailures() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var failureLog = new List(); + var operationCount = 0; + var deterministicRandom = new Random(123); // Fixed seed for predictable failures + + using (fileSystem.Events.Subscribe(args => + { + if (args.Phase != OperationPhase.Before) + { + return; + } + operationCount++; + + // Simulate controlled failure scenarios + var failureChance = deterministicRandom.NextDouble(); + var failureType = deterministicRandom.Next(1, 4); + + if (failureChance < 0.25) // 25% controlled failure rate + { + Exception failure = failureType switch + { + 1 => new IOException("Simulated disk space exhaustion"), + 2 => new UnauthorizedAccessException("Simulated permission denied"), + 3 => new DirectoryNotFoundException("Simulated path not found"), + _ => new InvalidOperationException("Simulated system error") + }; + + failureLog.Add($"Controlled failure #{operationCount}: {failure.Message} (Operation: {args.Operation})"); + args.SetResponse(new OperationResponse { Exception = failure }); + } + else + { + failureLog.Add($"Operation #{operationCount}: Success ({args.Operation})"); + } + })) + { + var testFiles = new[] + { + XFS.Path(@"C:\test1.txt"), + XFS.Path(@"C:\test2.txt"), + XFS.Path(@"C:\test3.txt"), + XFS.Path(@"C:\test4.txt") + }; + + foreach (var file in testFiles) + { + try + { + fileSystem.File.Create(file).Dispose(); + fileSystem.File.WriteAllText(file, "content"); + } + catch (Exception) + { + // ignored + } + + try + { + if (fileSystem.File.Exists(file)) + { + fileSystem.File.ReadAllText(file); + } + } + catch (Exception) + { + // ignored + } + } + } + + WriteTestOutput("Reliability Test Log:"); + foreach (var entry in failureLog) + { + WriteTestOutput($" {entry}"); + } + + // With deterministic random seed 123, we expect specific failure patterns + Assert.That(failureLog, Is.Not.Empty); + using (Assert.EnterMultipleScope()) + { + Assert.That(failureLog.Count(l => l.Contains("Controlled failure")), Is.GreaterThan(0)); + Assert.That(failureLog.Count(l => l.Contains("Success")), Is.GreaterThan(0)); + } + } + + /// + /// Verifies that the access pattern monitor properly detects and tracks suspicious file access activities + /// by escalating alert levels and triggering appropriate responses for excessive or anomalous access patterns. + /// + /// + /// This test focuses on monitoring read operations on files, evaluating their frequency, and + /// validating the system's ability to log and respond accordingly. It incorporates a simulation of + /// normal and suspicious access scenarios, ensuring that alerts are accurately triggered for potential risks. + /// Key behaviors tested: + /// - Initial access to a file triggers simple monitoring. + /// - Repeated and frequent access to the same file escalates alert levels progressively. + /// - Excessive access beyond a defined threshold triggers a security alert, resulting in an access denial response. + /// - Differentiates between normal and suspicious file access patterns. + /// Assertions include: + /// - The correct number of monitoring log entries are generated. + /// - Escalation of alert levels accurately corresponds to access frequency. + /// - The final log entry contains the expected security alert message. + /// + [Test] + public void Events_AccessPatternMonitor_ShouldTrackSuspiciousActivity() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var activityLog = new List(); + var accessCounts = new Dictionary(); + var alertLevel = 0; + + using (fileSystem.Events.Subscribe(args => + { + if (args.Phase != OperationPhase.Before || args.Operation != FileOperation.Read) + { + return; + } + accessCounts[args.Path] = (accessCounts.TryGetValue(args.Path, out var count) ? count : 0) + 1; + var accesses = accessCounts[args.Path]; + + switch (accesses) + { + // Monitor read access patterns only + case 1: + activityLog.Add($"First access to {args.Path} - monitoring initiated"); + break; + case 2: + alertLevel++; + activityLog.Add($"Repeated access to {args.Path} - elevated monitoring"); + break; + case 3: + alertLevel += 2; + activityLog.Add($"Frequent access to {args.Path} - high alert"); + break; + case 4: + alertLevel += 5; + activityLog.Add($"Excessive access to {args.Path} - security alert triggered"); + args.SetResponse(new OperationResponse + { + Exception = new UnauthorizedAccessException("Access denied: Suspicious activity pattern detected") + }); + break; + default: + // No action for access counts > 4 + break; + } + })) + { + var sensitiveFile = XFS.Path(@"C:\sensitive-data.txt"); + var normalFile = XFS.Path(@"C:\normal-file.txt"); + + fileSystem.File.Create(sensitiveFile).Dispose(); + fileSystem.File.Create(normalFile).Dispose(); + fileSystem.File.WriteAllText(sensitiveFile, "classified"); + fileSystem.File.WriteAllText(normalFile, "public"); + + // Simulate normal access pattern + fileSystem.File.ReadAllText(normalFile); + + // Simulate suspicious access pattern with proper exception handling + var securityAlertTriggered = false; + + try + { + fileSystem.File.ReadAllText(sensitiveFile); // 1st access + fileSystem.File.ReadAllText(sensitiveFile); // 2nd access + fileSystem.File.ReadAllText(sensitiveFile); // 3rd access + fileSystem.File.ReadAllText(sensitiveFile); // 4th access - should trigger alert + } + catch (UnauthorizedAccessException) + { + securityAlertTriggered = true; + } + + Assert.That(securityAlertTriggered, Is.True, "Security alert should have been triggered"); + } + + WriteTestOutput("Access Pattern Monitor Log:"); + foreach (var entry in activityLog) + { + WriteTestOutput($" {entry}"); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(activityLog, Has.Count.EqualTo(5)); // 1 normal file + 4 sensitive file accesses + Assert.That(alertLevel, Is.EqualTo(8)); // 1 + 2 + 5 from escalating alerts + } + Assert.That(activityLog.Last(), Does.Contain("security alert triggered")); + } + + /// + /// Validates that the performance profiler tracks and collects operation metrics for file system events + /// when the event system is enabled in the mock file system. + /// + /// + /// This method subscribes to file system events, performs various file operations (e.g., create, write, read, + /// set attributes, delete), and collects performance metrics such as operation execution durations. + /// The metrics are then analyzed to ensure the proper collection of data for all relevant file operations. + /// Additionally, assertions are made to verify that metrics are recorded for each operation type + /// and that each operation type has been executed multiple times. + /// + /// Thrown if the expected metrics are not recorded for all operations, + /// or if the operations are not performed multiple times as specified. + [Test] + public void Events_PerformanceProfiler_ShouldTrackOperationMetrics() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var performanceMetrics = new Dictionary>(); + var operationTimestamps = new Dictionary(); + + using (fileSystem.Events.Subscribe(args => + { + var key = $"{args.Path}_{args.Operation}_{args.Phase}"; + + if (args.Phase == OperationPhase.Before) + { + operationTimestamps[key] = DateTime.UtcNow; + } + else if (args.Phase == OperationPhase.After) + { + var beforeKey = key.Replace("_After", "_Before"); + if (!operationTimestamps.TryGetValue(beforeKey, out var timestamp)) + { + return; + } + var duration = (DateTime.UtcNow - timestamp).Ticks; + + if (!performanceMetrics.ContainsKey(args.Operation)) + { + performanceMetrics[args.Operation] = new List(); + } + + performanceMetrics[args.Operation].Add(duration); + operationTimestamps.Remove(beforeKey); + } + })) + { + var testFiles = new[] + { + XFS.Path(@"C:\perf-test-1.txt"), + XFS.Path(@"C:\perf-test-2.txt"), + XFS.Path(@"C:\perf-test-3.txt") + }; + + // Perform various operations to collect metrics + foreach (var file in testFiles) + { + fileSystem.File.Create(file).Dispose(); + fileSystem.File.WriteAllText(file, "performance test data"); + fileSystem.File.ReadAllText(file); + fileSystem.File.SetAttributes(file, FileAttributes.Archive); + } + + // Cleanup + foreach (var file in testFiles) + { + fileSystem.File.Delete(file); + } + } + + WriteTestOutput("Performance Metrics:"); + foreach (var metric in performanceMetrics) + { + var avgDuration = metric.Value.Average(); + WriteTestOutput($" {metric.Key}: {metric.Value.Count} operations, avg duration: {avgDuration:F2} ticks"); + } + + using (Assert.EnterMultipleScope()) + { + + // Verify we captured metrics for all operation types + Assert.That(performanceMetrics.ContainsKey(FileOperation.Create), Is.True); + Assert.That(performanceMetrics.ContainsKey(FileOperation.Write), Is.True); + Assert.That(performanceMetrics.ContainsKey(FileOperation.Read), Is.True); + Assert.That(performanceMetrics.ContainsKey(FileOperation.SetAttributes), Is.True); + Assert.That(performanceMetrics.ContainsKey(FileOperation.Delete), Is.True); + } + + // Each operation should have been performed multiple times + foreach (var metric in performanceMetrics.Values) + { + Assert.That(metric.Count, Is.GreaterThan(0)); + } + } + + /// + /// Determines whether the given input string is likely a typo of the target string, + /// based on a simple Levenshtein distance metric. + /// + /// The string to evaluate as a potential typo. + /// The reference string to compare against for typo detection. + /// A boolean value indicating whether the input string is likely a typo of the target string. + private static bool IsLikelyTypo(string input, string target) + { + if (string.IsNullOrEmpty(input) || string.IsNullOrEmpty(target)) + { + return false; + } + + // Simple Levenshtein distance check for typo detection + var distance = CalculateLevenshteinDistance(input.ToLowerInvariant(), target.ToLowerInvariant()); + return distance == 1 || distance == 2; // Allow 1-2 character differences + } + + /// + /// Calculates the Levenshtein distance between two strings, which is a measure of the number of single-character + /// edits (insertions, deletions, or substitutions) required to change one string into the other. + /// + /// The source string to compare. + /// The target string to compare against. + /// The Levenshtein distance as an integer, representing the number of edits required to transform the source string into the target string. + private static int CalculateLevenshteinDistance(string source, string target) + { + if (source.Length == 0) + { + return target.Length; + } + if (target.Length == 0) + { + return source.Length; + } + + var matrix = new int[source.Length + 1, target.Length + 1]; + + for (var i = 0; i <= source.Length; i++) + { + matrix[i, 0] = i; + } + for (var j = 0; j <= target.Length; j++) + { + matrix[0, j] = j; + } + + for (var i = 1; i <= source.Length; i++) + { + for (var j = 1; j <= target.Length; j++) + { + var cost = (target[j - 1] == source[i - 1]) ? 0 : 1; + matrix[i, j] = Math.Min( + Math.Min(matrix[i - 1, j] + 1, matrix[i, j - 1] + 1), + matrix[i - 1, j - 1] + cost); + } + } + + return matrix[source.Length, target.Length]; + } +} \ No newline at end of file diff --git a/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileSystemEventsTests.cs b/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileSystemEventsTests.cs new file mode 100644 index 000000000..149d1a968 --- /dev/null +++ b/tests/TestableIO.System.IO.Abstractions.TestingHelpers.Tests/MockFileSystemEventsTests.cs @@ -0,0 +1,840 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Serialization; +using System.Runtime.Serialization.Formatters.Binary; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using System.IO.Abstractions.TestingHelpers.Events; + +namespace System.IO.Abstractions.TestingHelpers.Tests; + +using XFS = MockUnixSupport; + +public class MockFileSystemEventsTests +{ + [Test] + public void Events_WhenNotEnabled_ShouldNotFireEvents() + { + var fileSystem = new MockFileSystem(); + var eventFired = false; + + // Events exist but are not enabled + Assert.That(fileSystem.Events, Is.Not.Null); + Assert.That(fileSystem.Events.IsEnabled, Is.False); + + // Subscribe should work but events won't fire + using (fileSystem.Events.Subscribe(args => eventFired = true)) + { + fileSystem.File.Create(XFS.Path(@"C:\test.txt")).Dispose(); + Assert.That(eventFired, Is.False); + } + } + + [Test] + public void Events_WhenEnabled_ShouldFireEvents() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var events = new List(); + + Assert.That(fileSystem.Events.IsEnabled, Is.True); + + using (fileSystem.Events.Subscribe(args => events.Add(args))) + { + fileSystem.File.Create(XFS.Path(@"C:\test.txt")).Dispose(); + + Assert.That(events.Count, Is.EqualTo(4)); // Create fires Create + Open events + Assert.That(events[0].Operation, Is.EqualTo(FileOperation.Create)); + Assert.That(events[0].Phase, Is.EqualTo(OperationPhase.Before)); + Assert.That(events[1].Operation, Is.EqualTo(FileOperation.Open)); + Assert.That(events[1].Phase, Is.EqualTo(OperationPhase.Before)); + Assert.That(events[2].Operation, Is.EqualTo(FileOperation.Open)); + Assert.That(events[2].Phase, Is.EqualTo(OperationPhase.After)); + Assert.That(events[3].Operation, Is.EqualTo(FileOperation.Create)); + Assert.That(events[3].Phase, Is.EqualTo(OperationPhase.After)); + } + } + + [Test] + public void Events_Subscribe_ToSpecificOperation_ShouldOnlyReceiveThoseEvents() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var createEvents = new List(); + var allEvents = new List(); + + using (fileSystem.Events.Subscribe(FileOperation.Create, args => createEvents.Add(args))) + using (fileSystem.Events.Subscribe(args => allEvents.Add(args))) + { + fileSystem.File.Create(XFS.Path(@"C:\test.txt")).Dispose(); + fileSystem.File.WriteAllText(XFS.Path(@"C:\test.txt"), "content"); + fileSystem.File.Delete(XFS.Path(@"C:\test.txt")); + + // Create subscription should only get Create events + Assert.That(createEvents.Count, Is.EqualTo(2)); // Before and After + foreach (var e in createEvents) + { + Assert.That(e.Operation, Is.EqualTo(FileOperation.Create)); + } + + // All subscription should get all events + Assert.That(allEvents.Count, Is.EqualTo(8)); // Create(4) + Write(2) + Delete(2) + } + } + + [Test] + public void Events_Subscribe_ToMultipleOperations_ShouldReceiveThoseEvents() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var modificationEvents = new List(); + + using (fileSystem.Events.Subscribe( + new[] { FileOperation.Create, FileOperation.Write, FileOperation.Delete }, + args => modificationEvents.Add(args))) + { + fileSystem.File.Create(XFS.Path(@"C:\test.txt")).Dispose(); + fileSystem.File.WriteAllText(XFS.Path(@"C:\test.txt"), "content"); + fileSystem.File.Delete(XFS.Path(@"C:\test.txt")); + + Assert.That(modificationEvents.Count, Is.EqualTo(6)); + var operations = modificationEvents.Select(e => e.Operation).Distinct().ToList(); + Assert.That(operations, Contains.Item(FileOperation.Create)); + Assert.That(operations, Contains.Item(FileOperation.Write)); + Assert.That(operations, Contains.Item(FileOperation.Delete)); + } + } + + [Test] + public void Events_Unsubscribe_ShouldStopReceivingEvents() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var events = new List(); + + var subscription = fileSystem.Events.Subscribe(args => events.Add(args)); + + fileSystem.File.Create(XFS.Path(@"C:\test1.txt")).Dispose(); + Assert.That(events.Count, Is.EqualTo(4)); // Create fires Create + Open events + + subscription.Dispose(); + + fileSystem.File.Create(XFS.Path(@"C:\test2.txt")).Dispose(); + Assert.That(events.Count, Is.EqualTo(4)); // Should still be 4 + } + + [Test] + public void Events_FileCreate_ShouldFireBeforeAndAfterEvents() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var events = new List(); + + using (fileSystem.Events.Subscribe(args => events.Add(args))) + { + var path = XFS.Path(@"C:\test.txt"); + fileSystem.File.Create(path).Dispose(); + + Assert.That(events.Count, Is.EqualTo(4)); // Create fires Create + Open events + + // Create Before event + Assert.That(events[0].Path, Is.EqualTo(path)); + Assert.That(events[0].Operation, Is.EqualTo(FileOperation.Create)); + Assert.That(events[0].ResourceType, Is.EqualTo(ResourceType.File)); + Assert.That(events[0].Phase, Is.EqualTo(OperationPhase.Before)); + + // Open Before event (Create calls Open) + Assert.That(events[1].Path, Is.EqualTo(path)); + Assert.That(events[1].Operation, Is.EqualTo(FileOperation.Open)); + Assert.That(events[1].ResourceType, Is.EqualTo(ResourceType.File)); + Assert.That(events[1].Phase, Is.EqualTo(OperationPhase.Before)); + + // Open After event + Assert.That(events[2].Path, Is.EqualTo(path)); + Assert.That(events[2].Operation, Is.EqualTo(FileOperation.Open)); + Assert.That(events[2].ResourceType, Is.EqualTo(ResourceType.File)); + Assert.That(events[2].Phase, Is.EqualTo(OperationPhase.After)); + + // Create After event + Assert.That(events[3].Path, Is.EqualTo(path)); + Assert.That(events[3].Operation, Is.EqualTo(FileOperation.Create)); + Assert.That(events[3].ResourceType, Is.EqualTo(ResourceType.File)); + Assert.That(events[3].Phase, Is.EqualTo(OperationPhase.After)); + } + } + + [Test] + public void Events_FileDelete_ShouldFireBeforeAndAfterEvents() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + fileSystem.AddFile(XFS.Path(@"C:\test.txt"), "content"); + + var events = new List(); + using (fileSystem.Events.Subscribe(args => events.Add(args))) + { + var path = XFS.Path(@"C:\test.txt"); + fileSystem.File.Delete(path); + + Assert.That(events.Count, Is.EqualTo(2)); + + // Before event + Assert.That(events[0].Path, Is.EqualTo(path)); + Assert.That(events[0].Operation, Is.EqualTo(FileOperation.Delete)); + Assert.That(events[0].ResourceType, Is.EqualTo(ResourceType.File)); + Assert.That(events[0].Phase, Is.EqualTo(OperationPhase.Before)); + + // After event + Assert.That(events[1].Path, Is.EqualTo(path)); + Assert.That(events[1].Operation, Is.EqualTo(FileOperation.Delete)); + Assert.That(events[1].ResourceType, Is.EqualTo(ResourceType.File)); + Assert.That(events[1].Phase, Is.EqualTo(OperationPhase.After)); + } + } + + [Test] + public void Events_FileWrite_ShouldFireBeforeAndAfterEvents() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var events = new List(); + + using (fileSystem.Events.Subscribe(args => events.Add(args))) + { + var path = XFS.Path(@"C:\test.txt"); + fileSystem.File.WriteAllText(path, "test content"); + + Assert.That(events.Count, Is.EqualTo(2)); + + // Before event + Assert.That(events[0].Path, Is.EqualTo(path)); + Assert.That(events[0].Operation, Is.EqualTo(FileOperation.Write)); + Assert.That(events[0].ResourceType, Is.EqualTo(ResourceType.File)); + Assert.That(events[0].Phase, Is.EqualTo(OperationPhase.Before)); + + // After event + Assert.That(events[1].Path, Is.EqualTo(path)); + Assert.That(events[1].Operation, Is.EqualTo(FileOperation.Write)); + Assert.That(events[1].ResourceType, Is.EqualTo(ResourceType.File)); + Assert.That(events[1].Phase, Is.EqualTo(OperationPhase.After)); + } + } + + [Test] + public void Events_DirectoryCreate_ShouldFireBeforeAndAfterEvents() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var events = new List(); + + using (fileSystem.Events.Subscribe(args => events.Add(args))) + { + var path = XFS.Path(@"C:\testdir"); + fileSystem.Directory.CreateDirectory(path); + + Assert.That(events.Count, Is.EqualTo(2)); + + // Before event + Assert.That(events[0].Path, Is.EqualTo(path)); + Assert.That(events[0].Operation, Is.EqualTo(FileOperation.Create)); + Assert.That(events[0].ResourceType, Is.EqualTo(ResourceType.Directory)); + Assert.That(events[0].Phase, Is.EqualTo(OperationPhase.Before)); + + // After event + Assert.That(events[1].Path, Is.EqualTo(path)); + Assert.That(events[1].Operation, Is.EqualTo(FileOperation.Create)); + Assert.That(events[1].ResourceType, Is.EqualTo(ResourceType.Directory)); + Assert.That(events[1].Phase, Is.EqualTo(OperationPhase.After)); + } + } + + [Test] + public void Events_DirectoryDelete_ShouldFireBeforeAndAfterEvents() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + fileSystem.AddDirectory(XFS.Path(@"C:\testdir")); + + var events = new List(); + using (fileSystem.Events.Subscribe(args => events.Add(args))) + { + var path = XFS.Path(@"C:\testdir"); + fileSystem.Directory.Delete(path); + + Assert.That(events.Count, Is.EqualTo(2)); + + // Before event + Assert.That(events[0].Path, Is.EqualTo(path)); + Assert.That(events[0].Operation, Is.EqualTo(FileOperation.Delete)); + Assert.That(events[0].ResourceType, Is.EqualTo(ResourceType.Directory)); + Assert.That(events[0].Phase, Is.EqualTo(OperationPhase.Before)); + + // After event + Assert.That(events[1].Path, Is.EqualTo(path)); + Assert.That(events[1].Operation, Is.EqualTo(FileOperation.Delete)); + Assert.That(events[1].ResourceType, Is.EqualTo(ResourceType.Directory)); + Assert.That(events[1].Phase, Is.EqualTo(OperationPhase.After)); + } + } + + [Test] + public void Events_CanCancelOperation_InBeforePhase() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + + using (fileSystem.Events.Subscribe(args => + { + if (args.Phase == OperationPhase.Before && args.Operation == FileOperation.Create) + { + args.SetResponse(new OperationResponse { Cancel = true }); + } + })) + { + Assert.Throws(() => + fileSystem.File.Create(XFS.Path(@"C:\test.txt"))); + + Assert.That(fileSystem.File.Exists(XFS.Path(@"C:\test.txt")), Is.False); + } + } + + [Test] + public void Events_CanThrowException_InBeforePhase() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var customException = new IOException("Disk full"); + + using (fileSystem.Events.Subscribe(args => + { + if (args.Phase == OperationPhase.Before && args.Operation == FileOperation.Write) + { + args.SetResponse(new OperationResponse { Exception = customException }); + } + })) + { + var ex = Assert.Throws(() => + fileSystem.File.WriteAllText(XFS.Path(@"C:\test.txt"), "content")); + + Assert.That(ex.Message, Is.EqualTo("Disk full")); + Assert.That(fileSystem.File.Exists(XFS.Path(@"C:\test.txt")), Is.False); + } + } + + [Test] + public void Events_SetResponse_InAfterPhase_ShouldThrow() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + InvalidOperationException thrownException = null; + + using (fileSystem.Events.Subscribe(args => + { + if (args.Phase == OperationPhase.After) + { + try + { + args.SetResponse(new OperationResponse()); + } + catch (InvalidOperationException ex) + { + thrownException = ex; + } + } + })) + { + fileSystem.File.Create(XFS.Path(@"C:\test.txt")).Dispose(); + + Assert.That(thrownException, Is.Not.Null); + Assert.That(thrownException.Message, Does.Contain("Before phase")); + } + } + + [Test] + public void Events_TrackOperationSequence() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var operations = new List(); + + using (fileSystem.Events.Subscribe(args => + { + if (args.Phase == OperationPhase.After) + { + operations.Add($"{args.Operation} {args.Path}"); + } + })) + { + fileSystem.File.Create(XFS.Path(@"C:\test.txt")).Dispose(); + fileSystem.File.WriteAllText(XFS.Path(@"C:\test.txt"), "content"); + fileSystem.File.Delete(XFS.Path(@"C:\test.txt")); + + Assert.That(operations.Count, Is.EqualTo(4)); // Create fires Create + Open, plus Write and Delete + Assert.That(operations[0], Is.EqualTo($"Open {XFS.Path(@"C:\test.txt")}")); // Create calls Open first + Assert.That(operations[1], Is.EqualTo($"Create {XFS.Path(@"C:\test.txt")}")); + Assert.That(operations[2], Is.EqualTo($"Write {XFS.Path(@"C:\test.txt")}")); + Assert.That(operations[3], Is.EqualTo($"Delete {XFS.Path(@"C:\test.txt")}")); + } + } + + [Test] + public void Events_SimulateDiskFullError_OnWrite() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + + using (fileSystem.Events.Subscribe(FileOperation.Write, args => + { + if (args.Phase == OperationPhase.Before) + { + args.SetResponse(new OperationResponse + { + Exception = new IOException("There is not enough space on the disk.") + }); + } + })) + { + // Create should work + fileSystem.File.Create(XFS.Path(@"C:\test.txt")).Dispose(); + Assert.That(fileSystem.File.Exists(XFS.Path(@"C:\test.txt")), Is.True); + + // Write should fail + var exception = Assert.Throws(() => + fileSystem.File.WriteAllText(XFS.Path(@"C:\test.txt"), "content")); + + Assert.That(exception.Message, Is.EqualTo("There is not enough space on the disk.")); + } + } + + [Test] + public void Events_SimulateReadOnlyFileSystem() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + fileSystem.AddFile(XFS.Path(@"C:\existing.txt"), "data"); + + using (fileSystem.Events.Subscribe( + new[] { FileOperation.Write, FileOperation.Create, FileOperation.Delete }, + args => + { + if (args.Phase == OperationPhase.Before) + { + args.SetResponse(new OperationResponse + { + Exception = new IOException("The media is write protected.") + }); + } + })) + { + // All write operations should fail + Assert.Throws(() => fileSystem.File.Create(XFS.Path(@"C:\new.txt"))); + Assert.Throws(() => fileSystem.File.WriteAllText(XFS.Path(@"C:\existing.txt"), "new")); + Assert.Throws(() => fileSystem.File.Delete(XFS.Path(@"C:\existing.txt"))); + + // Read should still work + var content = fileSystem.File.ReadAllText(XFS.Path(@"C:\existing.txt")); + Assert.That(content, Is.EqualTo("data")); + } + } + + [Test] + public void Events_SimulateFileLocking_ForSpecificFiles() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + fileSystem.AddFile(XFS.Path(@"C:\important.db"), "data"); + fileSystem.AddFile(XFS.Path(@"C:\normal.txt"), "text"); + + using (fileSystem.Events.Subscribe(args => + { + if (args.Phase == OperationPhase.Before && + args.Path.EndsWith(".db") && + args.Operation == FileOperation.Delete) + { + args.SetResponse(new OperationResponse + { + Exception = new IOException("The file is in use.") + }); + } + })) + { + // Can delete normal files + fileSystem.File.Delete(XFS.Path(@"C:\normal.txt")); + Assert.That(fileSystem.File.Exists(XFS.Path(@"C:\normal.txt")), Is.False); + + // Cannot delete .db files + var exception = Assert.Throws(() => + fileSystem.File.Delete(XFS.Path(@"C:\important.db"))); + + Assert.That(exception.Message, Is.EqualTo("The file is in use.")); + Assert.That(fileSystem.File.Exists(XFS.Path(@"C:\important.db")), Is.True); + } + } + + [Test] + public void Events_MultipleSubscriptions_AllReceiveEvents() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var handler1Events = new List(); + var handler2Events = new List(); + + using (fileSystem.Events.Subscribe(args => handler1Events.Add(args))) + using (fileSystem.Events.Subscribe(args => handler2Events.Add(args))) + { + fileSystem.File.Create(XFS.Path(@"C:\test.txt")).Dispose(); + + Assert.That(handler1Events.Count, Is.EqualTo(4)); // Create fires Create + Open events + Assert.That(handler2Events.Count, Is.EqualTo(4)); // Create fires Create + Open events + } + } + + [Test] + public void Events_ExceptionInHandler_DoesNotAffectOtherHandlers() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var handler2Called = false; + + using (fileSystem.Events.Subscribe(args => throw new InvalidOperationException("Handler 1 error"))) + using (fileSystem.Events.Subscribe(args => handler2Called = true)) + { + // The operation should throw the handler exception + Assert.Throws(() => fileSystem.File.Create(XFS.Path(@"C:\test.txt"))); + + // But handler2 should have been called + Assert.That(handler2Called, Is.True); + } + } + + [Test] + public void Events_ConcurrentSubscriptions_ShouldBeThreadSafe() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var exceptions = new List(); + var subscriptions = new List(); + var eventCount = 0; + + const int threadCount = 10; + const int subscriptionsPerThread = 50; + var threads = new Thread[threadCount]; + var barrier = new Barrier(threadCount); + + for (int i = 0; i < threadCount; i++) + { + threads[i] = new Thread(() => + { + try + { + barrier.SignalAndWait(); + + for (int j = 0; j < subscriptionsPerThread; j++) + { + var subscription = fileSystem.Events.Subscribe(args => + { + Interlocked.Increment(ref eventCount); + }); + + lock (subscriptions) + { + subscriptions.Add(subscription); + } + + Thread.Sleep(1); + } + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + } + }); + } + + foreach (var thread in threads) + { + thread.Start(); + } + + foreach (var thread in threads) + { + thread.Join(); + } + + Assert.That(exceptions, Is.Empty, $"Concurrent subscriptions threw exceptions: {string.Join(", ", exceptions.Select(e => e.Message))}"); + + fileSystem.File.Create(XFS.Path(@"C:\test.txt")).Dispose(); + + Assert.That(eventCount, Is.EqualTo(threadCount * subscriptionsPerThread * 4)); // Create fires 4 events + + foreach (var subscription in subscriptions) + { + subscription.Dispose(); + } + } + + [Test] + public void Events_ParallelOperations_ShouldFireAllEvents() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var eventCount = 0; + var exceptions = new List(); + + using (fileSystem.Events.Subscribe(args => Interlocked.Increment(ref eventCount))) + { + const int operationCount = 100; + var tasks = new Task[operationCount]; + + for (int i = 0; i < operationCount; i++) + { + int fileIndex = i; + tasks[i] = Task.Run(() => + { + try + { + var path = XFS.Path($@"C:\test{fileIndex}.txt"); + fileSystem.File.Create(path).Dispose(); + fileSystem.File.WriteAllText(path, "content"); + fileSystem.File.Delete(path); + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + } + }); + } + + Task.WaitAll(tasks); + + Assert.That(exceptions, Is.Empty, $"Parallel operations threw exceptions: {string.Join(", ", exceptions.Select(e => e.Message))}"); + Assert.That(eventCount, Is.EqualTo(operationCount * 8)); // Create(4) + Write(2) + Delete(2) + } + } + + [Test] + public void Events_SubscriptionDisposal_DuringEventFiring_ShouldNotFail() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var exceptions = new List(); + var subscription1 = fileSystem.Events.Subscribe(args => { }); + + var subscription2 = fileSystem.Events.Subscribe(args => + { + Task.Run(() => + { + try + { + subscription1.Dispose(); + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + } + }); + }); + + try + { + fileSystem.File.Create(XFS.Path(@"C:\test.txt")).Dispose(); + } + catch (Exception ex) + { + exceptions.Add(ex); + } + + subscription2.Dispose(); + + Assert.That(exceptions, Is.Empty, $"Disposal during event firing threw exceptions: {string.Join(", ", exceptions.Select(e => e.Message))}"); + } + + [Test] + public void Events_MultipleHandlerExceptions_ShouldAggregateExceptions() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + + using (fileSystem.Events.Subscribe(args => throw new InvalidOperationException("Handler 1 failed"))) + using (fileSystem.Events.Subscribe(args => throw new ArgumentException("Handler 2 failed"))) + using (fileSystem.Events.Subscribe(args => throw new IOException("Handler 3 failed"))) + { + var ex = Assert.Throws(() => + fileSystem.File.Create(XFS.Path(@"C:\test.txt")).Dispose()); + + Assert.That(ex.InnerExceptions.Count, Is.EqualTo(3)); + Assert.That(ex.InnerExceptions[0], Is.TypeOf()); + Assert.That(ex.InnerExceptions[1], Is.TypeOf()); + Assert.That(ex.InnerExceptions[2], Is.TypeOf()); + } + } + + [Test] + public void Events_SingleHandlerException_ShouldThrowDirectly() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + + using (fileSystem.Events.Subscribe(args => throw new InvalidOperationException("Single handler failed"))) + { + var ex = Assert.Throws(() => + fileSystem.File.Create(XFS.Path(@"C:\test.txt")).Dispose()); + + Assert.That(ex.Message, Is.EqualTo("Single handler failed")); + } + } + + [Test] + public void Events_ConcurrentSubscriptionAndUnsubscription_ShouldBeThreadSafe() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + var exceptions = new List(); + var running = true; + + var subscribeTask = Task.Run(() => + { + while (running) + { + try + { + var subscription = fileSystem.Events.Subscribe(args => { }); + Thread.Sleep(1); + subscription.Dispose(); + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + } + } + }); + + var operationTask = Task.Run(() => + { + var fileIndex = 0; + while (running) + { + try + { + fileSystem.File.Create(XFS.Path($@"C:\test{fileIndex++}.txt")).Dispose(); + Thread.Sleep(1); + } + catch (Exception ex) + { + lock (exceptions) + { + exceptions.Add(ex); + } + } + } + }); + + Thread.Sleep(500); + running = false; + + Task.WaitAll(subscribeTask, operationTask); + + Assert.That(exceptions, Is.Empty, $"Concurrent subscription/operation threw exceptions: {string.Join(", ", exceptions.Select(e => e.Message))}"); + } + +#if !NET9_0_OR_GREATER + [Test] + public void Events_MockFileSystemEvents_WithoutSubscriptions_ShouldBeSerializable() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + +#pragma warning disable SYSLIB0011 + var formatter = new BinaryFormatter(); +#pragma warning restore SYSLIB0011 + + using var stream = new MemoryStream(); + + Assert.DoesNotThrow(() => formatter.Serialize(stream, fileSystem.Events), + "MockFileSystemEvents without subscriptions should be serializable"); + + stream.Position = 0; + + MockFileSystemEvents deserializedEvents = null; + Assert.DoesNotThrow(() => + { + deserializedEvents = (MockFileSystemEvents)formatter.Deserialize(stream); + }, + "MockFileSystemEvents should be deserializable"); + + Assert.That(deserializedEvents, Is.Not.Null); + Assert.That(deserializedEvents.IsEnabled, Is.EqualTo(fileSystem.Events.IsEnabled)); + } +#endif + +#if !NET9_0_OR_GREATER + [Test] + public void Events_MockFileSystem_WithEvents_WithoutSubscriptions_ShouldBeSerializable() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + fileSystem.AddFile(XFS.Path(@"C:\test.txt"), "content"); + +#pragma warning disable SYSLIB0011 + var formatter = new BinaryFormatter(); +#pragma warning restore SYSLIB0011 + + using var stream = new MemoryStream(); + + Assert.DoesNotThrow(() => formatter.Serialize(stream, fileSystem), + "MockFileSystem with events should be serializable"); + + stream.Position = 0; + + MockFileSystem deserializedFileSystem = null; + Assert.DoesNotThrow(() => + { + deserializedFileSystem = (MockFileSystem)formatter.Deserialize(stream); + }, + "MockFileSystem with events should be deserializable"); + + Assert.That(deserializedFileSystem, Is.Not.Null); + Assert.That(deserializedFileSystem.Events, Is.Not.Null); + Assert.That(deserializedFileSystem.File.Exists(XFS.Path(@"C:\test.txt")), Is.True); + Assert.That(deserializedFileSystem.Events.IsEnabled, Is.True); + } +#endif + +#if !NET9_0_OR_GREATER + [Test] + public void Events_FileSystemOperationEventArgs_ShouldNotBeSerializable() + { + var args = new FileSystemOperationEventArgs( + XFS.Path(@"C:\test.txt"), + FileOperation.Create, + ResourceType.File, + OperationPhase.Before); + +#pragma warning disable SYSLIB0011 + var formatter = new BinaryFormatter(); +#pragma warning restore SYSLIB0011 + + using var stream = new MemoryStream(); + + Assert.Throws(() => formatter.Serialize(stream, args), + "FileSystemOperationEventArgs should not be serializable by design (contains Action delegates)"); + } + + [Test] + public void Events_MockFileSystemWithSubscriptions_CanBeSerializedWithActiveHandlers() + { + var fileSystem = new MockFileSystem(new MockFileSystemOptions { EnableEvents = true }); + + using var subscription = fileSystem.Events.Subscribe(args => { }); + +#pragma warning disable SYSLIB0011 + var formatter = new BinaryFormatter(); +#pragma warning restore SYSLIB0011 + + using var stream = new MemoryStream(); + + Assert.DoesNotThrow(() => formatter.Serialize(stream, fileSystem), + "MockFileSystem with active subscriptions should be serializable with [NonSerialized] handlers"); + + stream.Position = 0; + MockFileSystem deserializedFileSystem = null; + Assert.DoesNotThrow(() => + { + deserializedFileSystem = (MockFileSystem)formatter.Deserialize(stream); + }, + "MockFileSystem with events should be deserializable"); + + Assert.That(deserializedFileSystem, Is.Not.Null); + Assert.That(deserializedFileSystem.Events, Is.Not.Null); + Assert.That(deserializedFileSystem.Events.IsEnabled, Is.True); + } +#endif +} \ No newline at end of file