475 lines
18 KiB
C#
475 lines
18 KiB
C#
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<SqliteIndexStore> Open()
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db");
|
|
var store = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
|
|
await store.OpenAsync();
|
|
return store;
|
|
}
|
|
|
|
public static async Task<Source> 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<SqliteIndexStore>.Instance);
|
|
await first.OpenAsync();
|
|
var second = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => 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));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Read_only_store_opens_while_writer_holds_the_mutex()
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db");
|
|
await using var writer = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
|
|
await writer.OpenAsync();
|
|
await writer.AddSourceAsync(@"C:\data");
|
|
|
|
await using var reader = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance, readOnly: true);
|
|
await reader.OpenAsync();
|
|
Assert.False(reader.CanWrite);
|
|
var sources = await reader.Sources.GetAllAsync();
|
|
Assert.Single(sources);
|
|
Assert.Equal(@"C:\data", sources[0].LastRootPath);
|
|
|
|
var write = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => reader.RunWriteAsync(_ => Task.CompletedTask));
|
|
Assert.Contains("read-only", write.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|