using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using TeleWave.Infrastructure.Media; using Xunit; namespace TeleWave.Application.Tests.Media; /// /// Уборка перевалочного каталога: в uploads/ не должно оставаться файлов, на которые никто /// не сослался — это гигабайты на диске, невидимые в админке. /// public class UploadsCleanupTests : IDisposable { private readonly string _root = Path.Combine( Path.GetTempPath(), "telewave-tests", Guid.NewGuid().ToString("N") ); private MediaPathResolver Paths() => new(Options.Create(new StorageOptions { RootPath = _root })); [Fact] public async Task DeletesPartialUpload_WhenStreamFails() { var paths = Paths(); paths.EnsureDirectories(); var storage = new FileSystemMediaStorage(paths); await Assert.ThrowsAsync(() => storage.SaveUploadAsync(new FailingStream(), ".mkv", CancellationToken.None) ); Assert.Empty(Directory.GetFiles(paths.UploadsDir)); } [Fact] public void Sweep_DeletesStaleFilesOnly() { var paths = Paths(); paths.EnsureDirectories(); var stale = Path.Combine(paths.UploadsDir, "stale.mkv"); var fresh = Path.Combine(paths.UploadsDir, "fresh.mkv"); // Идущую заливку от брошенного файла отличает именно время последней записи. WriteAged(stale, TimeSpan.FromHours(25)); WriteAged(fresh, TimeSpan.Zero); UploadsCleaner.Sweep(paths, TimeSpan.FromHours(24), NullLogger.Instance); Assert.False(File.Exists(stale)); Assert.True(File.Exists(fresh)); } [Fact] public void Sweep_SurvivesMissingDirectory() { // Каталога может не быть на самом первом старте — это не повод падать. И заводить его // уборка тоже не должна: каталоги создаёт хранилище, когда в них появляется нужда. var paths = Paths(); UploadsCleaner.Sweep(paths, TimeSpan.FromHours(24), NullLogger.Instance); Assert.False(Directory.Exists(paths.UploadsDir)); } /// /// Кладёт файл и «состаривает» его. Отметку перепроверяем: Windows дописывает время последней /// записи при закрытии handle и может затереть выставленное сразу после записи. /// private static void WriteAged(string path, TimeSpan age) { File.WriteAllText(path, "x"); if (age == TimeSpan.Zero) return; var stamp = DateTime.UtcNow - age; for (var attempt = 0; attempt < 20; attempt++) { File.SetLastWriteTimeUtc(path, stamp); if ((File.GetLastWriteTimeUtc(path) - stamp).Duration() < TimeSpan.FromSeconds(1)) return; Thread.Sleep(20); } Assert.Fail("Не удалось выставить файлу время последней записи."); } public void Dispose() { GC.SuppressFinalize(this); if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); } /// Поток, обрывающийся на середине, — имитация отвалившегося клиента. private sealed class FailingStream : Stream { public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; public override long Length => throw new NotSupportedException(); public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override int Read(byte[] buffer, int offset, int count) => throw new IOException("Соединение разорвано."); public override ValueTask ReadAsync( Memory buffer, CancellationToken cancellationToken = default ) => throw new IOException("Соединение разорвано."); public override void Flush() { } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } }