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;
/// 后台文件大小修复的结果语义、批量边界与 S3 并发上限测试。
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();
s3.Setup(x => x.GetObjectSizeAsync(Bucket, "key-0", It.IsAny()))
.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();
s3.Setup(x => x.GetObjectSizeAsync(Bucket, "key-0", It.IsAny()))
.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();
s3.Setup(x => x.GetObjectSizeAsync(Bucket, "key-0", It.IsAny()))
.ReturnsAsync((long?)null);
var logger = new Mock>();
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();
s3.Setup(x => x.GetObjectSizeAsync(Bucket, "key-0", It.IsAny()))
.ThrowsAsync(new HttpRequestException("temporary"));
var logger = new Mock>();
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();
s3.Setup(x => x.GetObjectSizeAsync(
It.IsAny(),
It.IsAny(),
It.IsAny()))
.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(),
It.IsAny(),
It.IsAny()), 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();
s3.Setup(x => x.GetObjectSizeAsync(
It.IsAny(),
It.IsAny(),
It.IsAny()))
.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());
var act = () => processor.ProcessAsync(batchSize, maxConcurrency, CancellationToken.None);
await act.Should().ThrowAsync();
}
private static FileSizeVerificationProcessor CreateProcessor(
FileDbContext db,
Mock s3)
=> new(db, s3.Object, new Mock>().Object);
private static FileDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder()
.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> 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;
}
}
}
}