using Explorer.Application; using Explorer.Domain; using Explorer.Domain.Abstractions; using Explorer.Plugin.Abstractions; using Explorer.Storage.Sqlite; using Microsoft.Extensions.Logging.Abstractions; namespace Explorer.Application.Tests; public class BrowseServiceTests { [Fact] public async Task This_pc_shows_indexed_root_aggregate_size() { var (browse, store) = await CreateAsync(@"C:\", "C: (930.6 GB)"); var source = (await store.Sources.GetAllAsync()).Single(); source.LastIndexedUtc = DateTimeOffset.UtcNow; await store.Sources.UpsertAsync(source); var root = new IndexEntry { SourceId = source.Id, Name = "C:", NameNorm = "c:", IsDirectory = true, PathRel = "", LastSeenUtc = DateTimeOffset.UtcNow, AggregateSize = 1_073_741_824 }; await store.Entries.UpsertAsync(root); var listing = await browse.ListThisPcAsync(); var item = Assert.Single(listing.Items, i => i.FullPath.StartsWith(@"C:\", StringComparison.OrdinalIgnoreCase)); Assert.Equal(1_073_741_824, item.SizeBytes); Assert.Equal(250_000_000, item.FreeSpaceBytes); Assert.Equal(1_000_000_000, item.CapacityBytes); Assert.True(item.IsDirectory); Assert.Contains(listing.Items, i => i.FullPath == LocationRoots.RecycleBin); } [Fact] public async Task This_pc_lists_untracked_network_drives_as_importable() { var db = Path.Combine(Path.GetTempPath(), "ew-browse", Guid.NewGuid().ToString("N"), "index.db"); var store = new SqliteIndexStore(db, NullLogger.Instance); await store.OpenAsync(); var volumes = new BrowseVolumes { Online = [ new VolumeFingerprint { Kind = SourceKind.NtfsLocal, RootPath = @"C:\", DisplayName = "C:", VolumeSerial = 1, CapacityBytes = 1_000_000_000, FreeBytes = 250_000_000 }, new VolumeFingerprint { Kind = SourceKind.Smb, RootPath = @"Z:\", DisplayName = "Z: (Network)", Filesystem = "SMB", CapacityBytes = 2_000, FreeBytes = 500 } ] }; var env = new BrowseEnv(Path.GetDirectoryName(db)!); var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger.Instance); await sources.InitializeAsync(); var browse = new BrowseService( new BrowseEnumerator(), volumes, store, sources, new StorageProviderRegistry([], NullLogger.Instance), new CloudPlaceStore(env), new UiPreferencesStore(env)); var listing = await browse.ListThisPcAsync(); Assert.Contains(listing.Items, i => i.FullPath.StartsWith(@"C:\", StringComparison.OrdinalIgnoreCase) && !i.AvailableToImport); var imported = Assert.Single(listing.Items, i => i.AvailableToImport); Assert.Equal(@"Z:\", imported.FullPath); Assert.Contains("(Windows)", imported.DisplayName); await store.DisposeAsync(); } [Fact] public async Task Live_listing_overlays_folder_aggregate_from_index() { var (browse, store) = await CreateAsync(@"C:\", "C:"); var source = (await store.Sources.GetAllAsync()).Single(); source.LastIndexedUtc = DateTimeOffset.UtcNow; await store.Sources.UpsertAsync(source); var root = new IndexEntry { SourceId = source.Id, Name = "C:", NameNorm = "c:", IsDirectory = true, PathRel = "", LastSeenUtc = DateTimeOffset.UtcNow }; root.Id = await store.Entries.UpsertAsync(root); await store.Entries.UpsertAsync(new IndexEntry { SourceId = source.Id, ParentId = root.Id, Name = "Movies", NameNorm = "movies", IsDirectory = true, PathRel = "Movies", LastSeenUtc = DateTimeOffset.UtcNow, AggregateSize = 200 }); var listing = await browse.ListAsync(@"C:\"); var movies = Assert.Single(listing.Items, i => i.Name == "Movies"); Assert.Equal(200, movies.SizeBytes); Assert.Equal(250_000_000, movies.FreeSpaceBytes); var loose = Assert.Single(listing.Items, i => i.Name == "loose.txt"); Assert.Equal(10, loose.SizeBytes); } [Fact] public async Task Recycle_bin_listing_uses_catalog_summary() { var (browse, _) = await CreateAsync(@"C:\", "C:", new FakeRecycleBin(new RecycleBinSummary(3, 4096))); var listing = browse.ListRecycleBin(); Assert.Equal(LocationRoots.RecycleBin, listing.Path); Assert.Contains("3", listing.Error); Assert.Contains("4 KB", listing.Error, StringComparison.OrdinalIgnoreCase); var item = Assert.Single(listing.Items); Assert.True(item.Location.IsRecycleBin); Assert.Equal(4096, item.SizeBytes); var redirected = await browse.ListAsync(@"C:\$RECYCLE.BIN"); Assert.Equal(LocationRoots.RecycleBin, redirected.Path); } [Fact] public async Task Recycle_bin_folder_stays_hidden_when_protected_locations_are_shown() { var (browse, _) = await CreateAsync( @"C:\", "C:", enumerator: new RecycleFolderEnumerator(), configurePrefs: store => store.Save(UiPreferences.Default with { ShowProtectedSystemLocations = true })); var listing = await browse.ListAsync(@"C:\"); Assert.DoesNotContain(listing.Items, i => i.Name.Equals("$RECYCLE.BIN", StringComparison.OrdinalIgnoreCase)); Assert.Contains(listing.Items, i => i.Name == "Movies"); } [Fact] public async Task Cloud_quota_overrides_volume_space_and_null_is_safe() { var db = Path.Combine(Path.GetTempPath(), "ew-browse", Guid.NewGuid().ToString("N"), "index.db"); var store = new SqliteIndexStore(db, NullLogger.Instance); await store.OpenAsync(); var env = new BrowseEnv(Path.GetDirectoryName(db)!); var volumes = new BrowseVolumes(); var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger.Instance); await sources.InitializeAsync(); var places = new CloudPlaceStore(env); var cloudRoot = Path.Combine(env.DataDirectory, "OneDrive"); Directory.CreateDirectory(cloudRoot); places.Add("onedrive", cloudRoot, "OneDrive"); var quotaProvider = new QuotaProvider(cloudRoot, new ProviderQuota(cloudRoot, UsedBytes: 20, TotalBytes: 100, Label: "OneDrive")); var browse = new BrowseService( new BrowseEnumerator(), volumes, store, sources, new StorageProviderRegistry([quotaProvider], NullLogger.Instance), places, new UiPreferencesStore(env)); var listing = await browse.ListCloudAsync(); var item = Assert.Single(listing.Items); Assert.Equal(80, item.FreeSpaceBytes); Assert.Equal(100, item.CapacityBytes); var nullSafe = new BrowseService( new BrowseEnumerator(), volumes, store, sources, new StorageProviderRegistry([new QuotaProvider(cloudRoot, null)], NullLogger.Instance), places, new UiPreferencesStore(env)); var fallback = await nullSafe.ListCloudAsync(); Assert.Null(Assert.Single(fallback.Items).FreeSpaceBytes); var throwing = new BrowseService( new BrowseEnumerator(), volumes, store, sources, new StorageProviderRegistry( [new QuotaProvider(cloudRoot, null, throwOnQuota: true)], NullLogger.Instance), places, new UiPreferencesStore(env)); var safe = await throwing.ListCloudAsync(); Assert.Null(Assert.Single(safe.Items).FreeSpaceBytes); await store.DisposeAsync(); } private static async Task<(BrowseService Browse, SqliteIndexStore Store)> CreateAsync( string root, string display, IRecycleBinCatalog? recycle = null, IFileSystemEnumerator? enumerator = null, Action? configurePrefs = null) { var db = Path.Combine(Path.GetTempPath(), "ew-browse", Guid.NewGuid().ToString("N"), "index.db"); var store = new SqliteIndexStore(db, NullLogger.Instance); await store.OpenAsync(); var volumes = new BrowseVolumes { Online = [ new VolumeFingerprint { Kind = SourceKind.NtfsLocal, RootPath = root, DisplayName = display, VolumeSerial = 1, CapacityBytes = 1_000_000_000, FreeBytes = 250_000_000 } ] }; var env = new BrowseEnv(Path.GetDirectoryName(db)!); var sources = new SourceManager(store, volumes, env, new SystemClock(), NullLogger.Instance); await sources.InitializeAsync(); var prefs = new UiPreferencesStore(env); configurePrefs?.Invoke(prefs); var browse = new BrowseService( enumerator ?? new BrowseEnumerator(), volumes, store, sources, new StorageProviderRegistry([], NullLogger.Instance), new CloudPlaceStore(env), prefs, recycle: recycle); return (browse, store); } } file sealed class BrowseEnumerator : IFileSystemEnumerator { public IEnumerable EnumerateChildren(string directoryPath) => EnumerateChildrenSafe(directoryPath, out _); public FileSystemItem? GetItem(string path) => null; public IReadOnlyList EnumerateChildrenSafe(string directoryPath, out string? error) { error = null; return [ new FileSystemItem { FullPath = Path.Combine(directoryPath, "Movies"), Name = "Movies", IsDirectory = true }, new FileSystemItem { FullPath = Path.Combine(directoryPath, "loose.txt"), Name = "loose.txt", SizeBytes = 10 } ]; } } file sealed class RecycleFolderEnumerator : IFileSystemEnumerator { public IEnumerable EnumerateChildren(string directoryPath) => EnumerateChildrenSafe(directoryPath, out _); public FileSystemItem? GetItem(string path) => null; public IReadOnlyList EnumerateChildrenSafe(string directoryPath, out string? error) { error = null; return [ new FileSystemItem { FullPath = Path.Combine(directoryPath, "$RECYCLE.BIN"), Name = "$RECYCLE.BIN", IsDirectory = true, Attributes = AttributeFlags.Directory | AttributeFlags.Hidden | AttributeFlags.System }, new FileSystemItem { FullPath = Path.Combine(directoryPath, "Movies"), Name = "Movies", IsDirectory = true } ]; } } file sealed class FakeRecycleBin(RecycleBinSummary summary) : IRecycleBinCatalog { public RecycleBinSummary? TrySummarize(string? rootPath = null) => summary; } file sealed class QuotaProvider(string root, ProviderQuota? quota, bool throwOnQuota = false) : IStorageProvider { public ProviderManifest Manifest { get; } = new("onedrive", "OneDrive", "1", ProviderIsolation.InProcess); public ProviderCapability GetCapabilities() => ProviderCapability.Quota; public bool TryMatchRoot(string path) => CloudPath.IsUnder(root, path); public Task> GetItemStatesAsync(IReadOnlyList paths, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported)); public Task TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default) => throwOnQuota ? throw new InvalidOperationException("quota failed") : Task.FromResult(quota); } file sealed class BrowseVolumes : IVolumeService { public List Online { get; set; } = []; public IReadOnlyList EnumerateOnlineVolumes() => Online; public VolumeFingerprint? Probe(string path) => Online.FirstOrDefault(v => path.StartsWith(v.RootPath.TrimEnd('\\'), 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 BrowseEnv : IAppEnvironment { public BrowseEnv(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; } }