using Explorer.Application; using Explorer.Domain; using Explorer.Domain.Abstractions; using Explorer.Storage.Sqlite; using Microsoft.Extensions.Logging.Abstractions; namespace Explorer.Application.Tests; public class SourceManagerTests { [Fact] public async Task Removable_drive_keeps_stable_key_when_letter_changes() { var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db"); await using var store = new SqliteIndexStore(db, NullLogger.Instance); await store.OpenAsync(); var volumes = new FakeVolumes(); volumes.Online = [ new VolumeFingerprint { Kind = SourceKind.Removable, VolumeGuid = @"\\?\Volume{same}\", RootPath = @"E:\", DisplayName = "Stick E", VolumeSerial = 9, CapacityBytes = 64 } ]; var env = new FakeEnv(Path.GetDirectoryName(db)!); var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger.Instance); await mgr.InitializeAsync(); var first = (await store.Sources.GetAllAsync()).Single(); var key = first.StableKey; volumes.Online = [ new VolumeFingerprint { Kind = SourceKind.Removable, VolumeGuid = @"\\?\Volume{same}\", RootPath = @"G:\", DisplayName = "Stick G", VolumeSerial = 9, CapacityBytes = 64 } ]; await mgr.RefreshOnlineStateAsync(); var again = (await store.Sources.GetAllAsync()).Single(); Assert.Equal(key, again.StableKey); Assert.Equal(@"G:\", again.LastRootPath); } [Fact] public async Task Ensure_for_mapped_letter_creates_source() { var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db"); await using var store = new SqliteIndexStore(db, NullLogger.Instance); await store.OpenAsync(); var volumes = new FakeVolumes { Online = [ new VolumeFingerprint { Kind = SourceKind.Smb, RootPath = @"Z:\", DisplayName = "Z: (Network)", Filesystem = "SMB" } ] }; var env = new FakeEnv(Path.GetDirectoryName(db)!); var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger.Instance); volumes.Online = []; await mgr.InitializeAsync(); volumes.Online = [ new VolumeFingerprint { Kind = SourceKind.Smb, RootPath = @"Z:\", DisplayName = "Z: (Network)", Filesystem = "SMB" } ]; var source = await mgr.EnsureForPathAsync(@"Z:\Photos"); Assert.NotNull(source); Assert.Equal(@"Z:\", source!.LastRootPath); var found = await mgr.FindByPathAsync(@"Z:\"); Assert.Equal(source.Id, found!.Id); } [Fact] public async Task Forget_removes_disconnected_source_and_index_rows() { var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db"); await using var store = new SqliteIndexStore(db, NullLogger.Instance); await store.OpenAsync(); var volumes = new FakeVolumes { Online = [ new VolumeFingerprint { Kind = SourceKind.Smb, RootPath = @"Z:\", DisplayName = "Z: (Network)", Filesystem = "SMB" } ] }; var env = new FakeEnv(Path.GetDirectoryName(db)!); var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger.Instance); await mgr.InitializeAsync(); var source = await mgr.EnsureForPathAsync(@"Z:\"); Assert.NotNull(source); await store.Entries.UpsertAsync(new IndexEntry { SourceId = source.Id, Name = "Photos", NameNorm = "photos", IsDirectory = true, PathRel = "Photos", LastSeenUtc = DateTimeOffset.UtcNow }); Assert.False(mgr.CanForget(source)); Assert.False(await mgr.ForgetDisconnectedAsync(@"Z:\")); volumes.Online = []; await mgr.RefreshOnlineStateAsync(); source = (await store.Sources.GetAllAsync()).Single(); Assert.True(mgr.CanForget(source)); Assert.True(await mgr.ForgetDisconnectedAsync(@"Z:\")); Assert.Empty(await store.Sources.GetAllAsync()); Assert.Null(await store.Entries.GetRootAsync(source.Id)); } [Fact] public async Task Stale_scanning_status_clears_when_no_job_is_running() { var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db"); await using var store = new SqliteIndexStore(db, NullLogger.Instance); await store.OpenAsync(); var volumes = new FakeVolumes { Online = [ new VolumeFingerprint { Kind = SourceKind.NtfsLocal, RootPath = @"D:\", DisplayName = "Games (D:)", VolumeSerial = 42, CapacityBytes = 1000 } ] }; var env = new FakeEnv(Path.GetDirectoryName(db)!); var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger.Instance); await mgr.InitializeAsync(); var source = (await store.Sources.GetAllAsync()).Single(); await store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Scanning, null); await store.Sources.UpdateIndexedAsync(source.Id, DateTimeOffset.UtcNow, 1); await mgr.RefreshOnlineStateAsync(); source = (await store.Sources.GetAllAsync()).Single(); Assert.Equal(SourceStatus.Online, source.Status); Assert.True(source.IsIndexed); } [Fact] public async Task Forget_removes_unc_from_recents() { var dir = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N")); var db = Path.Combine(dir, "index.db"); await using var store = new SqliteIndexStore(db, NullLogger.Instance); var volumes = new FakeVolumes(); var env = new FakeEnv(dir); File.WriteAllLines(Path.Combine(dir, "recents.txt"), [@"\\old-server\share"]); var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger.Instance); await mgr.InitializeAsync(); Assert.Single(await store.Sources.GetAllAsync()); Assert.True(await mgr.ForgetDisconnectedAsync(@"\\old-server\share")); Assert.Empty(await store.Sources.GetAllAsync()); Assert.Empty(File.ReadAllLines(Path.Combine(dir, "recents.txt"))); } [Fact] public async Task Forget_then_rediscover_same_volume_guid() { var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db"); await using var store = new SqliteIndexStore(db, NullLogger.Instance); await store.OpenAsync(); var guid = @"\\?\Volume{phase2}\"; var volumes = new FakeVolumes { Online = [ new VolumeFingerprint { Kind = SourceKind.Removable, VolumeGuid = guid, RootPath = @"E:\", DisplayName = "Stick", VolumeSerial = 9, CapacityBytes = 64 } ] }; var env = new FakeEnv(Path.GetDirectoryName(db)!); var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger.Instance); await mgr.InitializeAsync(); Assert.Equal(guid, (await store.Sources.GetAllAsync()).Single().VolumeGuid); volumes.Online = []; await mgr.RefreshOnlineStateAsync(); Assert.True(await mgr.ForgetDisconnectedAsync(@"E:\")); Assert.Empty(await store.Sources.GetAllAsync()); volumes.Online = [ new VolumeFingerprint { Kind = SourceKind.Removable, VolumeGuid = guid, RootPath = @"F:\", DisplayName = "Stick", VolumeSerial = 9, CapacityBytes = 64 } ]; await mgr.RefreshOnlineStateAsync(); var restored = Assert.Single(await store.Sources.GetAllAsync()); Assert.Equal(guid, restored.VolumeGuid); Assert.Equal(@"F:\", restored.LastRootPath); } [Fact] public async Task Mapped_network_drive_is_not_auto_added_until_imported() { var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db"); await using var store = new SqliteIndexStore(db, NullLogger.Instance); await store.OpenAsync(); var volumes = new FakeVolumes { Online = [ new VolumeFingerprint { Kind = SourceKind.Smb, RootPath = @"Z:\", DisplayName = "Z: (Network)", Filesystem = "SMB" } ] }; var env = new FakeEnv(Path.GetDirectoryName(db)!); var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger.Instance); await mgr.InitializeAsync(); Assert.Empty(await store.Sources.GetAllAsync()); var untracked = Assert.Single(await mgr.ListUntrackedOnlineVolumesAsync()); Assert.Equal(@"Z:\", untracked.RootPath); var imported = await mgr.EnsureForPathAsync(@"Z:\"); Assert.NotNull(imported); Assert.False(imported!.IsIndexed); Assert.Empty(await mgr.ListUntrackedOnlineVolumesAsync()); Assert.Equal(imported.Id, (await store.Sources.GetAllAsync()).Single().Id); } } file sealed class FakeVolumes : IVolumeService { public List Online { get; set; } = []; public IReadOnlyList EnumerateOnlineVolumes() => Online; public VolumeFingerprint? Probe(string path) { var root = Path.GetPathRoot(path)?.TrimEnd('\\'); return Online.FirstOrDefault(v => v.RootPath.TrimEnd('\\').Equals(root, StringComparison.OrdinalIgnoreCase) || v.RootPath.Equals(path, StringComparison.OrdinalIgnoreCase)); } public bool IsPathReachable(string path) => Online.Any(v => path.StartsWith(v.RootPath.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase)); public VolumeSpace GetSpace(string path) { var fp = Probe(path); return new VolumeSpace(fp?.CapacityBytes, fp?.FreeBytes); } } file sealed class FakeEnv : IAppEnvironment { public FakeEnv(string dir) { DataDirectory = dir; Directory.CreateDirectory(dir); DatabasePath = Path.Combine(dir, "index.db"); LogDirectory = Path.Combine(dir, "logs"); Directory.CreateDirectory(LogDirectory); } public string DataDirectory { get; } public string DatabasePath { get; } public string LogDirectory { get; } }