test: add isolated backend integration evidence

This commit is contained in:
xiangning 2026-07-24 13:07:52 +08:00
parent 86af47ad09
commit b9c2a6b378
11 changed files with 317 additions and 27 deletions

View File

@ -440,7 +440,7 @@ git commit -m "refactor: enforce one top-level type per file"
- Modify: selected performance tests to emit `SOP_METRIC key=value` through `ITestOutputHelper`
- Create: `tests/BuildFlow.Workflow.Tests/Performance/OpenXmlMemoryProfileTests.cs`
- [ ] **Step 1: Add environment-driven isolated connection support**
- [x] **Step 1: Add environment-driven isolated connection support**
Replace fixed IM test endpoints with these environment variables and retain current values only as local fallbacks:
@ -456,11 +456,11 @@ BUILDFLOW_FILE_S3_TEST_ACCESS_KEY
BUILDFLOW_FILE_S3_TEST_SECRET_KEY
```
- [ ] **Step 2: Add a real MinIO S3 integration test**
- [x] **Step 2: Add a real MinIO S3 integration test**
The test must create a unique bucket, upload a non-empty object, download and byte-compare it, verify a zero-byte object, delete objects/bucket in `finally`, and emit object count/bytes as `SOP_METRIC`. It must fail rather than skip when its configured endpoint is unavailable.
- [ ] **Step 3: Add deterministic metric output**
- [x] **Step 3: Add deterministic metric output**
Emit at least these values from passing tests:
@ -478,7 +478,7 @@ excel_export_rows
`OpenXmlMemoryProfileTests` writes a large export to a temporary file from an `IAsyncEnumerable<string>`, samples `GC.GetTotalMemory(false)` while writing, asserts a conservative bounded peak delta, emits the observed bytes, and always deletes the file.
- [ ] **Step 4: Add random-port Docker Compose infrastructure**
- [x] **Step 4: Add random-port Docker Compose infrastructure**
Use pinned images already compatible with the repository:
@ -491,13 +491,13 @@ minio/minio:RELEASE.2025-04-22T22-12-26Z
Publish container ports with Docker-assigned random loopback ports, add health checks, use test-only credentials, and name volumes inside the per-run Compose project so teardown removes only SOP resources.
- [ ] **Step 5: Start the compose stack manually and run infrastructure tests**
- [x] **Step 5: Start the compose stack manually and run infrastructure tests**
Use a unique Compose project name, discover ports with `docker compose port`, export the variables above, then run PostgreSQL reliability/migration tests, Redis tests, IM SignalR integration tests, Monitoring UPSERT tests, and the MinIO test.
Expected: all PASS, zero skipped, and no access to developer databases or existing containers.
- [ ] **Step 6: Commit**
- [x] **Step 6: Commit**
```bash
git add scripts/backend-optimization-compose.yml tests

View File

@ -0,0 +1,70 @@
services:
postgres:
image: pgvector/pgvector:0.8.0-pg17
environment:
POSTGRES_USER: buildflow
POSTGRES_PASSWORD: buildflow
POSTGRES_DB: postgres
ports:
- "127.0.0.1::5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U buildflow -d postgres"]
interval: 2s
timeout: 5s
retries: 30
volumes:
- postgres-data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "127.0.0.1::6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 5s
retries: 30
volumes:
- redis-data:/data
mongo:
image: mongo:7
environment:
MONGO_INITDB_ROOT_USERNAME: buildflow
MONGO_INITDB_ROOT_PASSWORD: buildflow
ports:
- "127.0.0.1::27017"
healthcheck:
test:
- CMD-SHELL
- >-
mongosh --quiet --host 127.0.0.1 --port 27017
-u buildflow -p buildflow --authenticationDatabase admin
--eval "quit(db.adminCommand('ping').ok ? 0 : 1)"
interval: 2s
timeout: 5s
retries: 30
volumes:
- mongo-data:/data/db
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: buildflowminio
MINIO_ROOT_PASSWORD: buildflowminiosecret
ports:
- "127.0.0.1::9000"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9000/minio/health/live >/dev/null"]
interval: 2s
timeout: 5s
retries: 30
volumes:
- minio-data:/data
volumes:
postgres-data:
redis-data:
mongo-data:
minio-data:

View File

@ -7,11 +7,12 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using Xunit.Abstractions;
namespace BuildFlow.File.Tests;
/// <summary>后台文件大小修复的结果语义、批量边界与 S3 并发上限测试。</summary>
public class FileSizeVerificationProcessorTests
public class FileSizeVerificationProcessorTests(ITestOutputHelper output)
{
private const string Bucket = "files";
@ -153,6 +154,7 @@ public class FileSizeVerificationProcessorTests
maximum.Should().BeGreaterThan(1);
maximum.Should().BeLessThanOrEqualTo(3);
output.WriteLine($"SOP_METRIC file_size_max_s3_concurrency={maximum}");
}
[Theory]

View File

@ -0,0 +1,92 @@
using System.Text;
using File.Infrastructure.Storage;
using FluentAssertions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
using Xunit.Abstractions;
namespace BuildFlow.File.Tests;
/// <summary>真实 S3 兼容存储往返测试,供隔离 MinIO SOP 阶段执行。</summary>
public sealed class S3StorageServiceIntegrationTests(ITestOutputHelper output)
{
[Fact]
[Trait("Category", "External")]
public async Task UploadDownloadAndZeroByteObjectsRoundTrip()
{
var endpoint = GetRequiredVariable("BUILDFLOW_FILE_S3_TEST_ENDPOINT");
var accessKey = GetRequiredVariable("BUILDFLOW_FILE_S3_TEST_ACCESS_KEY");
var secretKey = GetRequiredVariable("BUILDFLOW_FILE_S3_TEST_SECRET_KEY");
var options = new Mock<IOptionsMonitor<S3Options>>();
options.SetupGet(x => x.CurrentValue).Returns(new S3Options
{
Endpoint = endpoint,
AccessKey = accessKey,
SecretKey = secretKey,
Region = "us-east-1",
});
var storage = new S3StorageService(options.Object);
var bucket = $"buildflow-sop-{Guid.NewGuid():N}";
var payloadKey = "payload.bin";
var zeroByteKey = "empty.bin";
var bucketCreated = false;
try
{
await storage.CreateBucketAsync(bucket, CancellationToken.None);
bucketCreated = true;
var payload = Encoding.UTF8.GetBytes("BuildFlow isolated MinIO round trip");
await using (var payloadStream = new MemoryStream(payload, writable: false))
{
await storage.UploadFileAsync(
bucket,
payloadKey,
payloadStream,
"application/octet-stream",
CancellationToken.None);
}
var download = await storage.DownloadFileAsync(bucket, payloadKey, CancellationToken.None);
await using (download.Stream)
await using (var downloaded = new MemoryStream())
{
await download.Stream.CopyToAsync(downloaded, CancellationToken.None);
downloaded.ToArray().Should().Equal(payload);
}
download.ContentType.Should().Be("application/octet-stream");
await using (var emptyStream = new MemoryStream(Array.Empty<byte>(), writable: false))
{
await storage.UploadFileAsync(
bucket,
zeroByteKey,
emptyStream,
"application/octet-stream",
CancellationToken.None);
}
(await storage.ObjectExistsAsync(bucket, zeroByteKey, CancellationToken.None)).Should().BeTrue();
(await storage.GetObjectSizeAsync(bucket, zeroByteKey, CancellationToken.None)).Should().Be(0);
var listed = await storage.ListObjectsAsync(bucket, null, 100, null, CancellationToken.None);
listed.Files.Select(x => x.Key).Should().BeEquivalentTo(payloadKey, zeroByteKey);
output.WriteLine($"SOP_METRIC s3_object_count={listed.Files.Count}");
output.WriteLine($"SOP_METRIC s3_object_bytes={payload.Length}");
}
finally
{
if (bucketCreated)
{
await storage.DeleteFileAsync(bucket, payloadKey, CancellationToken.None);
await storage.DeleteFileAsync(bucket, zeroByteKey, CancellationToken.None);
await storage.DeleteBucketAsync(bucket, CancellationToken.None);
}
}
}
private static string GetRequiredVariable(string name)
=> Environment.GetEnvironmentVariable(name)
?? throw new InvalidOperationException($"必须设置环境变量 {name} 才能运行真实 S3 集成测试。");
}

View File

@ -41,10 +41,10 @@ namespace BuildFlow.IM.Integration.Tests;
/// </remarks>
public sealed class ImServerFixture : IAsyncLifetime
{
/// <summary>本地基础设施连接串。指向独立测试库,与开发库(im_system)隔离。</summary>
private const string PostgresConnection = "Host=localhost;Port=5432;Database=im_system_test;Username=rag;Password=rag123";
private const string MongoConnection = "mongodb://mongo:mongo123@localhost:27017/im_system_test?authSource=admin";
private const string RedisConnection = "localhost:6379,abortConnect=false";
/// <summary>本地基础设施回退连接串。SOP 通过环境变量覆盖并指向随机端口容器。</summary>
private const string DefaultPostgresConnection = "Host=localhost;Port=5432;Database=im_system_test;Username=rag;Password=rag123";
private const string DefaultMongoConnection = "mongodb://mongo:mongo123@localhost:27017/im_system_test?authSource=admin";
private const string DefaultRedisConnection = "localhost:6379,abortConnect=false";
/// <summary>JWT 对称密钥,与 appsettings.json 中的 Jwt:SigningKey 保持一致,使 Hub 验签通过。</summary>
private const string SigningKey = "RagJwtSecretKey2026MustBeAtLeast32CharsLong!";
@ -78,15 +78,21 @@ public sealed class ImServerFixture : IAsyncLifetime
public async Task InitializeAsync()
{
_redis = await ConnectionMultiplexer.ConnectAsync(RedisConnection);
var postgresConnection = Environment.GetEnvironmentVariable("BUILDFLOW_IM_TEST_POSTGRES_CONNECTION")
?? DefaultPostgresConnection;
var mongoConnection = Environment.GetEnvironmentVariable("BUILDFLOW_IM_TEST_MONGO_CONNECTION")
?? DefaultMongoConnection;
var redisConnection = Environment.GetEnvironmentVariable("BUILDFLOW_IM_TEST_REDIS_CONNECTION")
?? DefaultRedisConnection;
_redis = await ConnectionMultiplexer.ConnectAsync(redisConnection);
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Default"] = PostgresConnection,
["ConnectionStrings:MongoDb"] = MongoConnection,
["ConnectionStrings:Default"] = postgresConnection,
["ConnectionStrings:MongoDb"] = mongoConnection,
});
var services = builder.Services;
@ -95,12 +101,12 @@ public sealed class ImServerFixture : IAsyncLifetime
services.TryAddSingleton<ICurrentUserContext, TestCurrentUserContext>();
services.AddScoped<AuditInterceptor>();
services.AddDbContext<ImDbContext>((sp, options) =>
options.UseNpgsql(PostgresConnection)
options.UseNpgsql(postgresConnection)
.AddInterceptors(sp.GetRequiredService<AuditInterceptor>()));
// —— MongoDB:消息正文(MongoDB.Entities 静态 DB.Default)——
var mongoDbName = MongoUrl.Create(MongoConnection).DatabaseName;
DB.InitAsync(mongoDbName, MongoClientSettings.FromConnectionString(MongoConnection)).GetAwaiter().GetResult();
var mongoDbName = MongoUrl.Create(mongoConnection).DatabaseName;
DB.InitAsync(mongoDbName, MongoClientSettings.FromConnectionString(mongoConnection)).GetAwaiter().GetResult();
// —— Redis:在线状态与未读计数的真实实现 ——
services.AddSingleton<IConnectionMultiplexer>(_ => _redis!);

View File

@ -20,15 +20,17 @@ namespace BuildFlow.IM.Tests.Chat;
/// </summary>
public class SendMessageCommandHandlerTests
{
/// <summary>本地测试 MongoDB 连接串(与集成测试共用,需本地 docker 起 mongo)。</summary>
private const string MongoConnection = "mongodb://mongo:mongo123@localhost:27017/im_unit_test?authSource=admin";
/// <summary>本地 MongoDB 回退连接串;SOP 通过环境变量覆盖并指向随机端口容器。</summary>
private const string DefaultMongoConnection = "mongodb://mongo:mongo123@localhost:27017/im_unit_test?authSource=admin";
static SendMessageCommandHandlerTests()
{
// MongoDB.Entities 用静态 DB.Default,进程内一次性初始化(幂等)。
// 消息正文落真实 Mongo,但本测试仅断言未读数语义,不校验 Mongo 内容。
var dbName = MongoUrl.Create(MongoConnection).DatabaseName!;
DB.InitAsync(dbName, MongoClientSettings.FromConnectionString(MongoConnection)).GetAwaiter().GetResult();
var connection = Environment.GetEnvironmentVariable("BUILDFLOW_IM_TEST_MONGO_CONNECTION")
?? DefaultMongoConnection;
var dbName = MongoUrl.Create(connection).DatabaseName!;
DB.InitAsync(dbName, MongoClientSettings.FromConnectionString(connection)).GetAwaiter().GetResult();
}
private static readonly Guid SenderId = Guid.Parse("22222222-2222-2222-2222-222222222222");

View File

@ -1,3 +1,4 @@
using System.Text;
using BuildFlow.Application.Chat.Commands;
using BuildFlow.Application.Chat.Handlers;
using BuildFlow.Application.Chat.Services;
@ -12,11 +13,12 @@ using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Xunit;
using Xunit.Abstractions;
namespace BuildFlow.Rag.Tests.Chat;
/// <summary>聊天生成上下文的消息数、Token 和三条生成路径复用测试。</summary>
public sealed class ChatContextWindowBuilderTests
public sealed class ChatContextWindowBuilderTests(ITestOutputHelper output)
{
[Fact]
public async Task ReturnsNewestMessagesWithinMessageLimitInChronologicalOrder()
@ -36,6 +38,11 @@ public sealed class ChatContextWindowBuilderTests
"message-098",
"message-099",
"message-100");
var estimatedTokens = result.Sum(message => Math.Max(
1,
(Encoding.UTF8.GetByteCount(message.Text) + 3) / 4));
output.WriteLine($"SOP_METRIC rag_context_message_count={result.Count}");
output.WriteLine($"SOP_METRIC rag_context_estimated_tokens={estimatedTokens}");
}
[Fact]

View File

@ -4,12 +4,15 @@ using FluentAssertions;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using Xunit;
using Xunit.Abstractions;
namespace BuildFlow.Rag.Tests.Chat;
/// <summary>Redis List 聊天缓存的并发追加、传输粒度、顺序和 TTL 集成测试。</summary>
[Collection(RedisChatMessageCacheCollection.Name)]
public sealed class RedisChatMessageCacheTests(RedisChatMessageCacheFixture fixture)
public sealed class RedisChatMessageCacheTests(
RedisChatMessageCacheFixture fixture,
ITestOutputHelper output)
{
[Fact]
public async Task ConcurrentAppendsPreserveEveryMessage()
@ -32,8 +35,10 @@ public sealed class RedisChatMessageCacheTests(RedisChatMessageCacheFixture fixt
storedMessages.Select(x => x.Id).Should().OnlyHaveUniqueItems();
(await database.KeyTypeAsync(RedisChatMessageCacheFixture.Key(conversationId)))
.Should().Be(RedisType.List);
(await database.ListLengthAsync(RedisChatMessageCacheFixture.Key(conversationId)))
.Should().Be(100);
var listLength = await database.ListLengthAsync(RedisChatMessageCacheFixture.Key(conversationId));
listLength.Should().Be(100);
output.WriteLine($"SOP_METRIC redis_concurrent_append_count={messages.Length}");
output.WriteLine($"SOP_METRIC redis_list_length={listLength}");
}
[Fact]

View File

@ -6,6 +6,7 @@ using Workflow.Application.Form.Aggregates;
using Workflow.Domain.Entities;
using Workflow.Domain.Enums;
using Xunit;
using Xunit.Abstractions;
namespace BuildFlow.Workflow.Tests.Performance;
@ -13,7 +14,9 @@ namespace BuildFlow.Workflow.Tests.Performance;
/// 表单聚合刷新查询次数门禁,避免目标行和统计字段触发 N+1。
/// </summary>
[Collection(PostgresWorkflowCollection.Name)]
public class FormAggregateQueryCountTests(PostgresWorkflowFixture fixture)
public class FormAggregateQueryCountTests(
PostgresWorkflowFixture fixture,
ITestOutputHelper output)
{
[Fact]
public async Task AggregateRefreshUsesConstantSelectCountForManyRowsAndFields()
@ -80,6 +83,7 @@ public class FormAggregateQueryCountTests(PostgresWorkflowFixture fixture)
await service.RefreshAsync(targetFormId, instanceId, CancellationToken.None);
interceptor.SelectCount.Should().Be(4);
output.WriteLine($"SOP_METRIC form_aggregate_select_count={interceptor.SelectCount}");
}
await using var verify = fixture.CreateDbContext();

View File

@ -9,6 +9,7 @@ using Workflow.Application.Form.Derivation;
using Workflow.Domain.Entities;
using Workflow.Domain.Enums;
using Xunit;
using Xunit.Abstractions;
namespace BuildFlow.Workflow.Tests.Performance;
@ -16,7 +17,9 @@ namespace BuildFlow.Workflow.Tests.Performance;
/// 导入任务有界批次事务和取消恢复测试。
/// </summary>
[Collection(PostgresWorkflowCollection.Name)]
public class ImportJobBatchingTests(PostgresWorkflowFixture fixture)
public class ImportJobBatchingTests(
PostgresWorkflowFixture fixture,
ITestOutputHelper output)
{
[Fact]
public async Task FourHundredOneRowsUseThreeBatchCommitsAndOneCompletionCommit()
@ -41,6 +44,7 @@ public class ImportJobBatchingTests(PostgresWorkflowFixture fixture)
CancellationToken.None);
transactions.CommitCount.Should().Be(4);
output.WriteLine($"SOP_METRIC import_commit_count={transactions.CommitCount}");
await using var verify = fixture.CreateDbContext();
var job = await verify.BuildFlowImportJobs.SingleAsync(x => x.Id == seeded.JobId);
job.Status.Should().Be(BuildFlowImportJobStatus.Success);

View File

@ -0,0 +1,98 @@
using System.Runtime.CompilerServices;
using FluentAssertions;
using Workflow.Application.BuildFlow.Common.Services;
using Xunit;
using Xunit.Abstractions;
namespace BuildFlow.Workflow.Tests.Performance;
/// <summary>OpenXML 顺序导出的托管内存峰值门禁。</summary>
public sealed class OpenXmlMemoryProfileTests(ITestOutputHelper output)
{
private const int ExportRows = 50_000;
private const long MaxPeakDeltaBytes = 128L * 1024 * 1024;
[Fact]
public async Task LargeAsyncExportKeepsManagedMemoryBounded()
{
var filePath = Path.Combine(
Path.GetTempPath(),
$"buildflow-openxml-memory-{Guid.NewGuid():N}.xlsx");
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var baselineBytes = GC.GetTotalMemory(true);
var peakBytes = baselineBytes;
void SampleManagedMemory()
{
peakBytes = Math.Max(peakBytes, GC.GetTotalMemory(false));
}
try
{
var service = new FormSchemaExcelService();
await using var file = new FileStream(
filePath,
FileMode.CreateNew,
FileAccess.ReadWrite,
FileShare.None,
81_920,
FileOptions.Asynchronous | FileOptions.SequentialScan);
await service.WriteExportAsync(
SchemaJson,
"内存门禁",
CreateRowsAsync(ExportRows, SampleManagedMemory),
file,
CancellationToken.None);
await file.FlushAsync(CancellationToken.None);
SampleManagedMemory();
new FileInfo(filePath).Length.Should().BeGreaterThan(0);
var peakDeltaBytes = Math.Max(0, peakBytes - baselineBytes);
peakDeltaBytes.Should().BeLessThan(
MaxPeakDeltaBytes,
"OpenXML 导出应按行顺序写入,不能把全部 JSON 行或工作表加载到内存");
output.WriteLine($"SOP_METRIC excel_export_peak_managed_bytes={peakDeltaBytes}");
output.WriteLine($"SOP_METRIC excel_export_rows={ExportRows}");
}
finally
{
System.IO.File.Delete(filePath);
}
}
private static async IAsyncEnumerable<string> CreateRowsAsync(
int count,
Action sampleManagedMemory,
[EnumeratorCancellation] CancellationToken ct = default)
{
for (var index = 0; index < count; index++)
{
ct.ThrowIfCancellationRequested();
if (index % 128 == 0)
{
sampleManagedMemory();
}
yield return $$"""{"id":{{index}},"name":"row-{{index}}","active":{{(index % 2 == 0).ToString().ToLowerInvariant()}}}""";
if (index % 1024 == 0)
{
await Task.Yield();
}
}
sampleManagedMemory();
}
private const string SchemaJson = """
{
"type": "object",
"properties": {
"id": { "type": "number", "title": "编号", "x-component": "InputNumber" },
"name": { "type": "string", "title": "名称", "x-component": "Input" },
"active": { "type": "boolean", "title": "启用", "x-component": "Switch" }
}
}
""";
}