222 lines
7.8 KiB
C#
222 lines
7.8 KiB
C#
using File.Application.Features.Files.Services;
|
|
using File.Domain.Entities.FileMeta;
|
|
using File.Infrastructure.Persistence;
|
|
using File.Infrastructure.Storage;
|
|
using FluentAssertions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace BuildFlow.File.Tests;
|
|
|
|
/// <summary>后台文件大小修复的结果语义、批量边界与 S3 并发上限测试。</summary>
|
|
public class FileSizeVerificationProcessorTests
|
|
{
|
|
private const string Bucket = "files";
|
|
|
|
[Fact]
|
|
public async Task PositiveObjectSizeUpdatesAndMarksVerified()
|
|
{
|
|
await using var db = CreateDbContext();
|
|
db.Files.Add(CreateFile(0));
|
|
await db.SaveChangesAsync();
|
|
var s3 = new Mock<IS3StorageService>();
|
|
s3.Setup(x => x.GetObjectSizeAsync(Bucket, "key-0", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(128L);
|
|
var processor = CreateProcessor(db, s3);
|
|
|
|
var processed = await processor.ProcessAsync(100, 4, CancellationToken.None);
|
|
|
|
processed.Should().Be(1);
|
|
var file = await db.Files.SingleAsync();
|
|
file.Size.Should().Be(128);
|
|
file.SizeVerified.Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ZeroByteObjectMarksVerified()
|
|
{
|
|
await using var db = CreateDbContext();
|
|
db.Files.Add(CreateFile(0));
|
|
await db.SaveChangesAsync();
|
|
var s3 = new Mock<IS3StorageService>();
|
|
s3.Setup(x => x.GetObjectSizeAsync(Bucket, "key-0", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(0L);
|
|
var processor = CreateProcessor(db, s3);
|
|
|
|
await processor.ProcessAsync(100, 4, CancellationToken.None);
|
|
|
|
var file = await db.Files.SingleAsync();
|
|
file.Size.Should().Be(0);
|
|
file.SizeVerified.Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MissingObjectLogsWarningAndMarksVerified()
|
|
{
|
|
await using var db = CreateDbContext();
|
|
db.Files.Add(CreateFile(0));
|
|
await db.SaveChangesAsync();
|
|
var s3 = new Mock<IS3StorageService>();
|
|
s3.Setup(x => x.GetObjectSizeAsync(Bucket, "key-0", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((long?)null);
|
|
var logger = new Mock<ILogger<FileSizeVerificationProcessor>>();
|
|
var processor = new FileSizeVerificationProcessor(db, s3.Object, logger.Object);
|
|
|
|
await processor.ProcessAsync(100, 4, CancellationToken.None);
|
|
|
|
var file = await db.Files.SingleAsync();
|
|
file.Size.Should().Be(0);
|
|
file.SizeVerified.Should().BeTrue();
|
|
WarningCount(logger).Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TransientFailureLogsWarningAndRemainsUnverified()
|
|
{
|
|
await using var db = CreateDbContext();
|
|
db.Files.Add(CreateFile(0));
|
|
await db.SaveChangesAsync();
|
|
var s3 = new Mock<IS3StorageService>();
|
|
s3.Setup(x => x.GetObjectSizeAsync(Bucket, "key-0", It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new HttpRequestException("temporary"));
|
|
var logger = new Mock<ILogger<FileSizeVerificationProcessor>>();
|
|
var processor = new FileSizeVerificationProcessor(db, s3.Object, logger.Object);
|
|
|
|
await processor.ProcessAsync(100, 4, CancellationToken.None);
|
|
|
|
var file = await db.Files.SingleAsync();
|
|
file.SizeVerified.Should().BeFalse();
|
|
WarningCount(logger).Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BatchSizeLimitsS3CallsToOldestFiles()
|
|
{
|
|
await using var db = CreateDbContext();
|
|
db.Files.AddRange(Enumerable.Range(0, 5).Select(CreateFile));
|
|
await db.SaveChangesAsync();
|
|
var s3 = new Mock<IS3StorageService>();
|
|
s3.Setup(x => x.GetObjectSizeAsync(
|
|
It.IsAny<string>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(1L);
|
|
var processor = CreateProcessor(db, s3);
|
|
|
|
var processed = await processor.ProcessAsync(2, 2, CancellationToken.None);
|
|
|
|
processed.Should().Be(2);
|
|
s3.Verify(x => x.GetObjectSizeAsync(
|
|
It.IsAny<string>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<CancellationToken>()), Times.Exactly(2));
|
|
var verifiedKeys = await db.Files
|
|
.Where(file => file.SizeVerified)
|
|
.OrderBy(file => file.CreatedAt)
|
|
.Select(file => file.S3Key)
|
|
.ToListAsync();
|
|
verifiedKeys.Should().Equal("key-0", "key-1");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task S3CallsRespectMaximumConcurrency()
|
|
{
|
|
await using var db = CreateDbContext();
|
|
db.Files.AddRange(Enumerable.Range(0, 8).Select(CreateFile));
|
|
await db.SaveChangesAsync();
|
|
var active = 0;
|
|
var maximum = 0;
|
|
var s3 = new Mock<IS3StorageService>();
|
|
s3.Setup(x => x.GetObjectSizeAsync(
|
|
It.IsAny<string>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(async (string _, string _, CancellationToken ct) =>
|
|
{
|
|
var current = Interlocked.Increment(ref active);
|
|
UpdateMaximum(ref maximum, current);
|
|
try
|
|
{
|
|
await Task.Delay(30, ct);
|
|
return 1L;
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Decrement(ref active);
|
|
}
|
|
});
|
|
var processor = CreateProcessor(db, s3);
|
|
|
|
await processor.ProcessAsync(8, 3, CancellationToken.None);
|
|
|
|
maximum.Should().BeGreaterThan(1);
|
|
maximum.Should().BeLessThanOrEqualTo(3);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, 1)]
|
|
[InlineData(1001, 1)]
|
|
[InlineData(1, 0)]
|
|
[InlineData(1, 33)]
|
|
public async Task OutOfRangeLimitsAreRejected(int batchSize, int maxConcurrency)
|
|
{
|
|
await using var db = CreateDbContext();
|
|
var processor = CreateProcessor(db, new Mock<IS3StorageService>());
|
|
|
|
var act = () => processor.ProcessAsync(batchSize, maxConcurrency, CancellationToken.None);
|
|
|
|
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
|
|
}
|
|
|
|
private static FileSizeVerificationProcessor CreateProcessor(
|
|
FileDbContext db,
|
|
Mock<IS3StorageService> s3)
|
|
=> new(db, s3.Object, new Mock<ILogger<FileSizeVerificationProcessor>>().Object);
|
|
|
|
private static FileDbContext CreateDbContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<FileDbContext>()
|
|
.UseInMemoryDatabase($"file-size-processor-{Guid.NewGuid():N}")
|
|
.Options;
|
|
return new FileDbContext(options);
|
|
}
|
|
|
|
private static FileMetaEntity CreateFile(int index)
|
|
=> new()
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = $"file-{index}.bin",
|
|
S3Key = $"key-{index}",
|
|
S3Bucket = Bucket,
|
|
ContentType = "application/octet-stream",
|
|
OwnerId = "user-001",
|
|
Size = 0,
|
|
SizeVerified = false,
|
|
CreatedBy = "test",
|
|
CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMinutes(index),
|
|
UpdatedBy = "test",
|
|
UpdatedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMinutes(index),
|
|
OperatorIP = "127.0.0.1",
|
|
};
|
|
|
|
private static int WarningCount(Mock<ILogger<FileSizeVerificationProcessor>> logger)
|
|
=> logger.Invocations.Count(invocation =>
|
|
invocation.Method.Name == nameof(ILogger.Log)
|
|
&& invocation.Arguments[0] is LogLevel.Warning);
|
|
|
|
private static void UpdateMaximum(ref int maximum, int candidate)
|
|
{
|
|
while (true)
|
|
{
|
|
var observed = Volatile.Read(ref maximum);
|
|
if (candidate <= observed
|
|
|| Interlocked.CompareExchange(ref maximum, candidate, observed) == observed)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|