using Explorer.Application; using Explorer.Domain; using Explorer.Domain.Abstractions; namespace Explorer.Application.Tests; public class FolderSyncPlannerTests { private static readonly DateTimeOffset T0 = DateTimeOffset.Parse("2024-06-01T12:00:00Z"); [Fact] public void Copy_update_adds_and_overwrites_but_does_not_delete() { var fs = Tree() .Dir(@"C:\src") .File(@"C:\src\new.txt", 10, T0) .File(@"C:\src\same.txt", 20, T0) .File(@"C:\src\changed.txt", 30, T0) .Dir(@"C:\src\empty") .Dir(@"C:\dst") .File(@"C:\dst\same.txt", 20, T0) .File(@"C:\dst\changed.txt", 11, T0.AddSeconds(-10)) .File(@"C:\dst\orphan.txt", 4, T0); var plan = Build(fs, SyncMode.CopyUpdate); Assert.True(plan.CanEnqueue); Assert.DoesNotContain(plan.Operations, o => o.Op == TransferOp.Delete); Assert.Contains(plan.Operations, o => o.Op == TransferOp.Copy && o.SourcePath == @"C:\src\new.txt"); Assert.Contains(plan.Operations, o => o.Op == TransferOp.Copy && o.SourcePath == @"C:\src\changed.txt"); Assert.Contains(plan.SyncPreview, r => r.RelativePath == "same.txt" && r.Action == "Skip"); Assert.Contains(plan.SyncPreview, r => r.RelativePath == "empty" && r.Action == "Copy"); Assert.DoesNotContain(plan.SyncPreview, r => r.RelativePath == "orphan.txt"); } [Fact] public void Dest_newer_is_skipped_with_a_warning() { var fs = Tree() .Dir(@"C:\src") .File(@"C:\src\photo.jpg", 100, T0) .Dir(@"C:\dst") .File(@"C:\dst\photo.jpg", 80, T0.AddSeconds(5)); var plan = Build(fs, SyncMode.CopyUpdate); Assert.False(plan.CanEnqueue); Assert.Empty(plan.Operations); Assert.Contains(plan.Issues, i => i.Severity == PlanIssueSeverity.Warning && i.Message.Contains("newer")); Assert.Contains(plan.SyncPreview, r => r.Action == "Skip" && r.Detail == "Destination is newer."); } [Fact] public void Mirror_lists_destination_only_deletes() { var fs = Tree() .Dir(@"C:\src") .File(@"C:\src\keep.txt", 8, T0) .Dir(@"C:\dst") .File(@"C:\dst\keep.txt", 8, T0) .File(@"C:\dst\gone.txt", 3, T0) .Dir(@"C:\dst\old"); var plan = Build(fs, SyncMode.Mirror); Assert.True(plan.CanEnqueue); Assert.Contains(plan.Operations, o => o.Op == TransferOp.Delete && o.SourcePath == @"C:\dst\gone.txt"); Assert.Contains(plan.Operations, o => o.Op == TransferOp.Delete && o.SourcePath == @"C:\dst\old"); Assert.Contains(plan.SyncPreview, r => r.RelativePath == "gone.txt" && r.Action == "Delete"); } [Fact] public void Nested_or_same_folders_are_errors() { var fs = Tree().Dir(@"C:\src").Dir(@"C:\src\nested"); var planner = new FolderSyncPlanner(); var same = planner.Build(@"C:\src", @"C:\src", SyncMode.CopyUpdate, "", fs, _ => true); Assert.False(same.CanEnqueue); Assert.Contains(same.Issues, i => i.Message.Contains("different", StringComparison.OrdinalIgnoreCase)); var nested = planner.Build(@"C:\src", @"C:\src\nested", SyncMode.CopyUpdate, "", fs, _ => true); Assert.False(nested.CanEnqueue); Assert.Contains(nested.Issues, i => i.Message.Contains("contain", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Offline_destination_is_an_error_not_an_empty_mirror() { var fs = Tree() .Dir(@"C:\src") .File(@"C:\src\a.txt", 1, T0) .Dir(@"Z:\backup") .File(@"Z:\backup\a.txt", 1, T0) .File(@"Z:\backup\only-dest.txt", 2, T0); var plan = new FolderSyncPlanner().Build( @"C:\src", @"Z:\backup", SyncMode.Mirror, "", fs, path => path.StartsWith(@"C:\", StringComparison.OrdinalIgnoreCase)); Assert.False(plan.CanEnqueue); Assert.Empty(plan.Operations); Assert.Contains(plan.Issues, i => i.Message == "Destination is not available."); } [Fact] public void Copy_update_allows_a_missing_dest_folder_when_the_parent_is_reachable() { var fs = Tree() .Dir(@"C:\src") .File(@"C:\src\a.txt", 1, T0) .Dir(@"C:\dst"); var plan = new FolderSyncPlanner().Build( @"C:\src", @"C:\dst\new", SyncMode.CopyUpdate, "", fs, path => !path.Equals(@"C:\dst\new", StringComparison.OrdinalIgnoreCase)); Assert.True(plan.CanEnqueue); Assert.Contains(plan.Operations, o => o.Op == TransferOp.Copy && o.DestinationPath == @"C:\dst\new\a.txt"); } [Fact] public void Excludes_and_online_only_files_are_skipped() { var fs = Tree() .Dir(@"C:\src") .File(@"C:\src\keep.txt", 1, T0) .File(@"C:\src\skip.tmp", 1, T0) .File(@"C:\src\cloud.bin", 1, T0) .Dir(@"C:\dst"); var plan = new FolderSyncPlanner().Build( @"C:\src", @"C:\dst", SyncMode.CopyUpdate, "*.tmp", fs, _ => true, item => item.Name == "cloud.bin"); Assert.Contains(plan.Operations, o => o.SourcePath.EndsWith("keep.txt", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith("skip.tmp", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith("cloud.bin", StringComparison.OrdinalIgnoreCase)); Assert.Contains(plan.Issues, i => i.Message.Contains("Online-only", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Reparse_directories_are_not_walked_or_copied() { var fs = Tree() .Dir(@"C:\src") .Dir(@"C:\src\link", AttributeFlags.Directory | AttributeFlags.ReparsePoint) .File(@"C:\src\link\secret.txt", 9, T0) .Dir(@"C:\dst"); var plan = Build(fs, SyncMode.CopyUpdate); Assert.DoesNotContain(plan.Operations, o => o.SourcePath.Contains("secret", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith(@"\link", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Remap_replaces_the_volume_root_when_the_guid_is_unique() { var sources = new List { new() { StableKey = "usb", DisplayName = "Photos", VolumeGuid = @"{abc}", LastRootPath = @"F:\" } }; Assert.Equal(@"F:\photos", FolderSyncPlanner.RemapToVolume(@"E:\photos", "{abc}", sources)); Assert.Equal(@"E:\photos", FolderSyncPlanner.RemapToVolume(@"E:\photos", "{abc}", [])); } private static OperationPlan Build(IFileSystemEnumerator fs, SyncMode mode) => new FolderSyncPlanner().Build(@"C:\src", @"C:\dst", mode, "", fs, _ => true); private static TreeEnumerator Tree() => new(); } sealed class TreeEnumerator : IFileSystemEnumerator { private readonly Dictionary _items = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _children = new(StringComparer.OrdinalIgnoreCase); public TreeEnumerator Dir(string path, int attributes = AttributeFlags.Directory) { Add(new FileSystemItem { FullPath = path, Name = PathRules.GetFileName(path), IsDirectory = true, Attributes = attributes }); return this; } public TreeEnumerator File(string path, long size, DateTimeOffset modified) { Add(new FileSystemItem { FullPath = path, Name = PathRules.GetFileName(path), SizeBytes = size, ModifiedUtc = modified }); return this; } public IEnumerable EnumerateChildren(string directoryPath) => EnumerateChildrenSafe(directoryPath, out _); public FileSystemItem? GetItem(string path) => _items.TryGetValue(Key(path), out var item) ? item : null; public IReadOnlyList EnumerateChildrenSafe(string directoryPath, out string? error) { error = null; return _children.TryGetValue(Key(directoryPath), out var list) ? list : []; } private void Add(FileSystemItem item) { _items[Key(item.FullPath)] = item; var parent = PathRules.Parent(item.FullPath); if (!_children.TryGetValue(Key(parent), out var list)) { list = []; _children[Key(parent)] = list; } list.Add(item); } private static string Key(string path) => PathRules.FromExtended(path).TrimEnd('\\'); }