Implemented a new background service, UploadsCleanupBackgroundService, to manage stale uploads in the uploads directory. Enhanced error handling in the media upload process to ensure incomplete uploads are deleted immediately upon failure, preventing unnecessary storage usage. Updated the .env.example and StorageOptions to include configuration for stale upload retention time. Improved documentation to reflect these changes and clarify the upload process.
122 lines
4.5 KiB
C#
122 lines
4.5 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using TeleWave.Infrastructure.Media;
|
|
using Xunit;
|
|
|
|
namespace TeleWave.Application.Tests.Media;
|
|
|
|
/// <summary>
|
|
/// Уборка перевалочного каталога: в <c>uploads/</c> не должно оставаться файлов, на которые никто
|
|
/// не сослался — это гигабайты на диске, невидимые в админке.
|
|
/// </summary>
|
|
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<IOException>(() =>
|
|
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()
|
|
{
|
|
// Каталога может не быть на самом первом старте — это не повод падать.
|
|
UploadsCleaner.Sweep(Paths(), TimeSpan.FromHours(24), NullLogger.Instance);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Кладёт файл и «состаривает» его. Отметку перепроверяем: Windows дописывает время последней
|
|
/// записи при закрытии handle и может затереть выставленное сразу после записи.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Поток, обрывающийся на середине, — имитация отвалившегося клиента.</summary>
|
|
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<int> ReadAsync(
|
|
Memory<byte> 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();
|
|
}
|
|
}
|