using Explorer.Domain; using Explorer.Domain.Abstractions; using Explorer.Storage.Sqlite; using Microsoft.Extensions.Logging.Abstractions; namespace Explorer.Storage.Tests; internal static class Stores { public static async Task Open() { var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db"); var store = new SqliteIndexStore(path, NullLogger.Instance); await store.OpenAsync(); return store; } public static async Task AddSourceAsync(this IIndexStore store, string root) { var s = new Source { StableKey = Guid.NewGuid().ToString("N"), Kind = SourceKind.NtfsLocal, DisplayName = "Test", LastRootPath = root, Status = SourceStatus.Online }; s.Id = await store.Sources.UpsertAsync(s); return s; } } public class SchemaTests { [Fact] public async Task Opens_and_quick_check_ok() { await using var store = await Stores.Open(); var check = await store.QuickCheckAsync(); Assert.Equal("ok", check, ignoreCase: true); await store.Analysis.EnsureReadyAsync(); await store.Analysis.EnsureReadyAsync(); Assert.Empty(await store.Analysis.GetDirectoryRootsAsync()); Assert.Equal(await store.Analysis.GetIndexStampAsync(), await store.Analysis.GetIndexStampAsync()); } [Fact] public async Task Source_roundtrip_and_identity_fields() { await using var store = await Stores.Open(); var source = await store.AddSourceAsync(@"T:\"); Assert.True(source.Id > 0); source.VolumeGuid = @"\\?\Volume{xyz}\"; source.VolumeSerial = 1234; await store.Sources.UpsertAsync(source); var loaded = await store.Sources.GetAsync(source.Id); Assert.Equal(@"\\?\Volume{xyz}\", loaded!.VolumeGuid); Assert.Equal(1234, loaded.VolumeSerial); } } public class EntryAndSearchTests { [Fact] public async Task Upsert_is_idempotent_and_search_filters_work() { await using var store = await Stores.Open(); var source = await store.AddSourceAsync(@"C:\data"); var root = new IndexEntry { SourceId = source.Id, Name = "data", NameNorm = "data", IsDirectory = true, PathRel = "", LastSeenUtc = DateTimeOffset.UtcNow, ScanGeneration = 1, Status = EntryStatus.Present }; root.Id = await store.Entries.UpsertAsync(root); var file = new IndexEntry { SourceId = source.Id, ParentId = root.Id, Name = "Movie.mkv", NameNorm = "movie.mkv", Extension = "mkv", IsDirectory = false, SizeBytes = 12L * 1024 * 1024 * 1024, PathRel = "Movie.mkv", LastSeenUtc = DateTimeOffset.UtcNow, ModifiedUtc = DateTimeOffset.Parse("2024-06-01Z"), ScanGeneration = 1 }; var id1 = await store.Entries.UpsertAsync(file); file.SizeBytes = 13L * 1024 * 1024 * 1024; var id2 = await store.Entries.UpsertAsync(file); Assert.Equal(id1, id2); var found = await store.Search.SearchAsync(new SearchRequest { Name = "*.mkv", MinSize = 10L * 1024 * 1024 * 1024, SourceIds = [source.Id], Take = 50 }); Assert.Contains(found, e => e.Name == "Movie.mkv"); var byDate = await store.Search.SearchAsync(new SearchRequest { ModifiedBefore = DateTimeOffset.Parse("2025-01-01Z"), SourceIds = [source.Id] }); Assert.Contains(byDate, e => e.Name == "Movie.mkv"); } [Fact] public async Task Folder_aggregates_and_tombstones() { await using var store = await Stores.Open(); var source = await store.AddSourceAsync(@"C:\lib"); var root = new IndexEntry { SourceId = source.Id, Name = "lib", NameNorm = "lib", IsDirectory = true, PathRel = "", LastSeenUtc = DateTimeOffset.UtcNow }; root.Id = await store.Entries.UpsertAsync(root); var dir = new IndexEntry { SourceId = source.Id, ParentId = root.Id, Name = "Movies", NameNorm = "movies", IsDirectory = true, PathRel = "Movies", LastSeenUtc = DateTimeOffset.UtcNow }; dir.Id = await store.Entries.UpsertAsync(dir); var file = new IndexEntry { SourceId = source.Id, ParentId = dir.Id, Name = "a.bin", NameNorm = "a.bin", IsDirectory = false, SizeBytes = 1000, PathRel = @"Movies\a.bin", LastSeenUtc = DateTimeOffset.UtcNow }; file.Id = await store.Entries.UpsertAsync(file); await store.Entries.ApplySizeDeltaToAncestorsAsync(dir.Id, 1000, 1, 0); var loadedDir = await store.Entries.GetAsync(dir.Id); Assert.Equal(1000, loadedDir!.AggregateSize); await store.Entries.TombstoneAsync(file.Id, DateTimeOffset.UtcNow); loadedDir = await store.Entries.GetAsync(dir.Id); Assert.Equal(0, loadedDir!.AggregateSize); var tomb = await store.Entries.GetAsync(file.Id); Assert.Equal(EntryStatus.Deleted, tomb!.Status); await store.Entries.DeleteExpiredTombstonesAsync(DateTimeOffset.UtcNow.AddDays(1)); Assert.Null(await store.Entries.GetAsync(file.Id)); } [Fact] public async Task Rename_updates_subtree_paths() { await using var store = await Stores.Open(); var source = await store.AddSourceAsync(@"C:\r"); var root = new IndexEntry { SourceId = source.Id, Name = "r", NameNorm = "r", IsDirectory = true, PathRel = "", LastSeenUtc = DateTimeOffset.UtcNow }; root.Id = await store.Entries.UpsertAsync(root); var dir = new IndexEntry { SourceId = source.Id, ParentId = root.Id, Name = "Old", NameNorm = "old", IsDirectory = true, PathRel = "Old", LastSeenUtc = DateTimeOffset.UtcNow }; dir.Id = await store.Entries.UpsertAsync(dir); var file = new IndexEntry { SourceId = source.Id, ParentId = dir.Id, Name = "f.txt", NameNorm = "f.txt", PathRel = @"Old\f.txt", LastSeenUtc = DateTimeOffset.UtcNow }; file.Id = await store.Entries.UpsertAsync(file); await store.Entries.RenameSubtreePathAsync(source.Id, "Old", "New"); var moved = await store.Entries.GetAsync(file.Id); Assert.Equal(@"New\f.txt", moved!.PathRel); } [Fact] public async Task Duplicate_candidates_are_same_size_groups() { await using var store = await Stores.Open(); var source = await store.AddSourceAsync(@"C:\d"); var root = new IndexEntry { SourceId = source.Id, Name = "d", NameNorm = "d", IsDirectory = true, PathRel = "", LastSeenUtc = DateTimeOffset.UtcNow }; root.Id = await store.Entries.UpsertAsync(root); foreach (var name in new[] { "a.bin", "b.bin", "c.bin" }) { await store.Entries.UpsertAsync(new IndexEntry { SourceId = source.Id, ParentId = root.Id, Name = name, NameNorm = name, IsDirectory = false, SizeBytes = name == "c.bin" ? 50 : 100, PathRel = name, LastSeenUtc = DateTimeOffset.UtcNow }); } await store.Hashes.EnqueueSizeCollisionsAsync(source.Id); var work = await store.Hashes.DequeueAsync(10); Assert.Equal(2, work.Count); Assert.All(work, w => Assert.Equal(100, w.SizeBytes)); } } public class TransferStoreTests { [Fact] public async Task Incomplete_jobs_roundtrip_and_history_is_append_only() { await using var store = await Stores.Open(); var queued = new TransferJob { Op = TransferOp.Copy, SourcePath = @"D:\src\a.txt", DestinationPath = @"E:\dst\a.txt", Status = TransferStatus.Queued, CreatedUtc = DateTimeOffset.UtcNow, RetryCount = 0, WaitReason = null }; queued.Id = await store.Transfers.InsertAsync(queued); Assert.True(queued.Id > 0); Assert.True(queued.SortOrder > 0); queued.Status = TransferStatus.Failed; queued.Error = "disk full"; queued.RetryCount = 1; queued.BytesDone = 12; queued.CurrentPath = @"D:\src\a.txt"; await store.Transfers.UpdateAsync(queued); var incomplete = await store.Transfers.GetIncompleteAsync(); var loaded = Assert.Single(incomplete); Assert.Equal(queued.Id, loaded.Id); Assert.Equal(TransferStatus.Failed, loaded.Status); Assert.Equal("disk full", loaded.Error); Assert.Equal(1, loaded.RetryCount); Assert.Equal(12, loaded.BytesDone); Assert.Equal(@"D:\src\a.txt", loaded.CurrentPath); queued.Status = TransferStatus.Done; queued.Error = null; queued.Dismissed = true; await store.Transfers.UpdateAsync(queued); Assert.Empty(await store.Transfers.GetIncompleteAsync()); var history = await store.Transfers.GetHistoryAsync(10); var done = Assert.Single(history); Assert.Equal(TransferStatus.Done, done.Status); Assert.True(done.Dismissed); } [Fact] public async Task Schema_is_version_4() { await using var store = await Stores.Open(); await store.Transfers.InsertAsync(new TransferJob { Op = TransferOp.Delete, SourcePath = @"C:\temp\a.txt", DestinationPath = "recycle", Status = TransferStatus.Queued, CreatedUtc = DateTimeOffset.UtcNow, AdditionalSources = [@"C:\temp\a.txt"] }); var loaded = Assert.Single(await store.Transfers.GetIncompleteAsync()); Assert.Equal(TransferOp.Delete, loaded.Op); Assert.Equal([@"C:\temp\a.txt"], loaded.AdditionalSources); } [Fact] public async Task Waiting_jobs_are_incomplete_not_history() { await using var store = await Stores.Open(); var job = new TransferJob { Op = TransferOp.Move, SourcePath = @"\\nas\share\file.bin", DestinationPath = @"F:\backup\file.bin", Status = TransferStatus.Waiting, WaitReason = "Destination unavailable", CreatedUtc = DateTimeOffset.UtcNow }; job.Id = await store.Transfers.InsertAsync(job); var incomplete = Assert.Single(await store.Transfers.GetIncompleteAsync()); Assert.Equal(TransferStatus.Waiting, incomplete.Status); Assert.Equal("Destination unavailable", incomplete.WaitReason); Assert.Empty(await store.Transfers.GetHistoryAsync(10)); } } public class FileRelationTests { [Fact] public async Task Relations_roundtrip_and_normalize_entry_order() { await using var store = await Stores.Open(); var source = await store.AddSourceAsync(@"C:\d"); var a = await store.Entries.UpsertAsync(new IndexEntry { SourceId = source.Id, Name = "a.bin", NameNorm = "a.bin", PathRel = "a.bin", SizeBytes = 8, LastSeenUtc = DateTimeOffset.UtcNow }); var b = await store.Entries.UpsertAsync(new IndexEntry { SourceId = source.Id, Name = "b.bin", NameNorm = "b.bin", PathRel = "b.bin", SizeBytes = 8, LastSeenUtc = DateTimeOffset.UtcNow }); await store.Relations.UpsertAsync(new FileRelation { LeftEntryId = b, RightEntryId = a, Kind = FileRelationKind.IntentionalDuplicate, Origin = FileRelationOrigin.User, CreatedUtc = DateTimeOffset.UtcNow }); var relation = Assert.Single(await store.Relations.GetAmongAsync([a, b])); Assert.Equal(Math.Min(a, b), relation.LeftEntryId); Assert.Equal(Math.Max(a, b), relation.RightEntryId); Assert.Equal(FileRelationKind.IntentionalDuplicate, relation.Kind); await store.Relations.DeleteAmongAsync([a, b], [FileRelationKind.IntentionalDuplicate]); Assert.Empty(await store.Relations.GetAmongAsync([a, b])); } [Fact] public async Task Rename_batch_roundtrip_and_mark_undone() { await using var store = await Stores.Open(); var id = await store.RenameBatches.CreateAsync( [ new RenameBatchItem(@"C:\a\old.txt", @"C:\a\new.txt", 0), new RenameBatchItem(@"C:\a\one.txt", @"C:\a\two.txt", 1) ]); var batch = await store.RenameBatches.GetLatestUndoableAsync(); Assert.NotNull(batch); Assert.Equal(id, batch.Id); Assert.Equal(2, batch.Items.Count); Assert.Equal(@"C:\a\old.txt", batch.Items[0].OldPath); Assert.Equal(@"C:\a\two.txt", batch.Items[1].NewPath); await store.RenameBatches.MarkUndoneAsync(id); Assert.Null(await store.RenameBatches.GetLatestUndoableAsync()); } [Fact] public async Task Sync_profile_roundtrip() { await using var store = await Stores.Open(); var id = await store.SyncProfiles.UpsertAsync(new SyncProfile { Name = "Photos", SourcePath = @"C:\src", DestPath = @"D:\dst", Mode = SyncMode.CopyUpdate, Excludes = "*.tmp", AutoRun = true, SourceVolumeGuid = @"{src}", DestVolumeGuid = @"{dst}", CreatedUtc = DateTimeOffset.Parse("2024-06-01T12:00:00Z") }); Assert.True(id > 0); var loaded = Assert.Single(await store.SyncProfiles.ListAsync()); Assert.Equal(id, loaded.Id); Assert.Equal("Photos", loaded.Name); Assert.Equal(@"D:\dst", loaded.DestPath); Assert.Equal(SyncMode.CopyUpdate, loaded.Mode); Assert.Equal("*.tmp", loaded.Excludes); Assert.True(loaded.AutoRun); Assert.Equal(@"{dst}", loaded.DestVolumeGuid); loaded.Mode = SyncMode.Mirror; loaded.AutoRun = false; loaded.LastStatus = "Queued 1 copy"; await store.SyncProfiles.UpsertAsync(loaded); var updated = await store.SyncProfiles.GetAsync(id); Assert.Equal(SyncMode.Mirror, updated!.Mode); Assert.False(updated.AutoRun); Assert.Equal("Queued 1 copy", updated.LastStatus); await store.SyncProfiles.DeleteAsync(id); Assert.Empty(await store.SyncProfiles.ListAsync()); } [Fact] public async Task Operation_profile_roundtrip() { await using var store = await Stores.Open(); var id = await store.OperationProfiles.UpsertAsync(new OperationProfile { Name = "Archive folder", SourcePath = @"C:\src", DestPath = @"D:\dst", RequireGitClean = true, DoCompress = true, ArchiveFormat = ArchiveFormat.SevenZip, DoCopy = false, DoRename = true, RenamePrefix = "x_", Excludes = ".git\nbin", AutoRun = false, SourceVolumeGuid = @"{src}", DestVolumeGuid = @"{dst}", IsBuiltIn = true, CreatedUtc = DateTimeOffset.Parse("2024-06-01T12:00:00Z") }); Assert.True(id > 0); var loaded = Assert.Single(await store.OperationProfiles.ListAsync()); Assert.Equal(id, loaded.Id); Assert.Equal("Archive folder", loaded.Name); Assert.True(loaded.RequireGitClean); Assert.True(loaded.DoCompress); Assert.Equal(ArchiveFormat.SevenZip, loaded.ArchiveFormat); Assert.True(loaded.DoRename); Assert.Equal("x_", loaded.RenamePrefix); Assert.Equal(".git\nbin", loaded.Excludes); Assert.True(loaded.IsBuiltIn); Assert.Equal(@"{dst}", loaded.DestVolumeGuid); loaded.DoCopy = true; loaded.LastStatus = "Queued 1 compress"; await store.OperationProfiles.UpsertAsync(loaded); var updated = await store.OperationProfiles.GetAsync(id); Assert.True(updated!.DoCopy); Assert.Equal("Queued 1 compress", updated.LastStatus); await store.OperationProfiles.DeleteAsync(id); Assert.Empty(await store.OperationProfiles.ListAsync()); } } public class IndexStoreLockTests { [Fact] public async Task Second_writer_on_same_path_is_rejected() { var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db"); await using var first = new SqliteIndexStore(path, NullLogger.Instance); await first.OpenAsync(); var second = new SqliteIndexStore(path, NullLogger.Instance); var ex = await Assert.ThrowsAsync(() => second.OpenAsync()); Assert.Contains("already in use", ex.Message, StringComparison.OrdinalIgnoreCase); await first.DisposeAsync(); await second.OpenAsync(); await second.DisposeAsync(); } [Fact] public void Mutex_name_is_stable_for_the_same_path() { var path = @"C:\Users\me\AppData\Local\ExplorerWorkbench\index.db"; Assert.Equal(IndexStoreLock.MutexNameFor(path), IndexStoreLock.MutexNameFor(path)); Assert.StartsWith(@"Local\ExplorerWorkbench-Index-", IndexStoreLock.MutexNameFor(path)); } }