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; } } } }