2026-07-24 09:22:17 +08:00

185 lines
8.3 KiB
C#

using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Monitoring.Application.Sync;
using Monitoring.Domain.Entities.ExternalSystem;
using Monitoring.Domain.Enums;
using Monitoring.Domain.Services;
using Monitoring.Domain.Values;
namespace BuildFlow.Monitoring.Tests.Sync;
/// <summary>Monitoring 同步记录批量 UPSERT、并发幂等和日志安全集成测试。</summary>
[Collection(PostgresMonitoringCollection.Name)]
public sealed class MonitoringUpsertTests(PostgresMonitoringFixture fixture)
{
[Fact]
public async Task Persists1001RecordsInThreeParameterizedUpsertsWithoutRecordSelects()
{
var interceptor = new CountingUpsertInterceptor();
await using var db = fixture.CreateDbContext(interceptor);
var system = CreateSystem("batch-upsert");
db.ExternalSystems.Add(system);
await db.SaveChangesAsync();
var records = Enumerable.Range(0, 1001)
.Select(index => new SyncedRecordData(
$"order-{index}",
"order",
$$"""{"value":{{index}}}""",
DateTime.UtcNow.AddMinutes(index)))
.ToArray();
using var services = CreateServices(new StaticExternalConnector(CreateResult(records)));
var processor = new SyncProcessor(db, services, new RecordingLogger<SyncProcessor>());
var job = await processor.ExecuteAsync(system, "test", CancellationToken.None);
var upsertCount = interceptor.UpsertCommandCount;
var selectCount = interceptor.RecordSelectCount;
var allParameterized = interceptor.AllUpsertsParameterized;
db.ChangeTracker.Clear();
var persistedCount = await db.SyncedRecords.CountAsync(x => x.ExternalSystemId == system.Id);
job.Status.Should().Be(SyncJobStatusEnum.Succeeded);
persistedCount.Should().Be(1001);
upsertCount.Should().Be(3);
selectCount.Should().Be(0);
allParameterized.Should().BeTrue();
}
[Fact]
public async Task ConcurrentSynchronizationsUpsertOneCoherentLatestRecord()
{
var systemId = Guid.NewGuid();
await using (var seedDb = fixture.CreateDbContext())
{
seedDb.ExternalSystems.Add(CreateSystem("concurrent", systemId));
await seedDb.SaveChangesAsync();
}
var occurredAtA = new DateTime(2026, 7, 23, 1, 0, 0, DateTimeKind.Utc);
var occurredAtB = occurredAtA.AddMinutes(1);
var interceptor = new CountingUpsertInterceptor(concurrentSelectParticipants: 2);
await using var dbA = fixture.CreateDbContext(interceptor);
await using var dbB = fixture.CreateDbContext(interceptor);
var systemA = await dbA.ExternalSystems.SingleAsync(x => x.Id == systemId);
var systemB = await dbB.ExternalSystems.SingleAsync(x => x.Id == systemId);
using var servicesA = CreateServices(new StaticExternalConnector(CreateResult(
[new SyncedRecordData("shared-order", "order", """{"source":"A"}""", occurredAtA)])));
using var servicesB = CreateServices(new StaticExternalConnector(CreateResult(
[new SyncedRecordData("shared-order", "order", """{"source":"B"}""", occurredAtB)])));
var processorA = new SyncProcessor(dbA, servicesA, new RecordingLogger<SyncProcessor>());
var processorB = new SyncProcessor(dbB, servicesB, new RecordingLogger<SyncProcessor>());
var jobs = await Task.WhenAll(
processorA.ExecuteAsync(systemA, "concurrent-a", CancellationToken.None),
processorB.ExecuteAsync(systemB, "concurrent-b", CancellationToken.None));
jobs.Should().OnlyContain(job => job.Status == SyncJobStatusEnum.Succeeded);
await using var verifyDb = fixture.CreateDbContext();
var records = await verifyDb.SyncedRecords
.Where(x => x.ExternalSystemId == systemId)
.ToListAsync();
records.Should().ContainSingle();
var record = records.Single();
new[] { jobs[0].Id, jobs[1].Id }.Should().Contain(record.SyncJobId);
if (record.SyncJobId == jobs[0].Id)
{
record.OccurredAt.Should().Be(occurredAtA);
record.Data.Payload["source"]?.ToString().Should().Be("A");
}
else
{
record.OccurredAt.Should().Be(occurredAtB);
record.Data.Payload["source"]?.ToString().Should().Be("B");
}
}
[Fact]
public async Task SequentialSynchronizationReplacesJobTimeAndPayload()
{
await using var db = fixture.CreateDbContext();
var system = CreateSystem("sequential");
db.ExternalSystems.Add(system);
await db.SaveChangesAsync();
var firstTime = new DateTime(2026, 7, 23, 2, 0, 0, DateTimeKind.Utc);
var secondTime = firstTime.AddHours(1);
using var firstServices = CreateServices(new StaticExternalConnector(CreateResult(
[new SyncedRecordData("updated-order", "order", """{"version":"first"}""", firstTime)])));
var firstJob = await new SyncProcessor(db, firstServices, new RecordingLogger<SyncProcessor>())
.ExecuteAsync(system, "first", CancellationToken.None);
using var secondServices = CreateServices(new StaticExternalConnector(CreateResult(
[new SyncedRecordData("updated-order", "order", """{"version":"second"}""", secondTime)])));
var secondJob = await new SyncProcessor(db, secondServices, new RecordingLogger<SyncProcessor>())
.ExecuteAsync(system, "second", CancellationToken.None);
db.ChangeTracker.Clear();
var record = await db.SyncedRecords.SingleAsync(x =>
x.ExternalSystemId == system.Id && x.ExternalId == "updated-order");
firstJob.Status.Should().Be(SyncJobStatusEnum.Succeeded);
secondJob.Status.Should().Be(SyncJobStatusEnum.Succeeded);
record.SyncJobId.Should().Be(secondJob.Id);
record.OccurredAt.Should().Be(secondTime);
record.Data.Payload["version"]?.ToString().Should().Be("second");
}
[Fact]
public async Task InvalidJsonPreservesRawPayloadAndLogsIdentifiersOnly()
{
const string rawPayload = "invalid-json-sensitive-payload";
const string externalId = "invalid-order";
const string recordType = "order";
await using var db = fixture.CreateDbContext();
var system = CreateSystem("invalid-json");
db.ExternalSystems.Add(system);
await db.SaveChangesAsync();
using var services = CreateServices(new StaticExternalConnector(CreateResult(
[new SyncedRecordData(externalId, recordType, rawPayload, DateTime.UtcNow)])));
var logger = new RecordingLogger<SyncProcessor>();
var processor = new SyncProcessor(db, services, logger);
var job = await processor.ExecuteAsync(system, "invalid-json", CancellationToken.None);
db.ChangeTracker.Clear();
var record = await db.SyncedRecords.SingleAsync(x => x.ExternalSystemId == system.Id);
var warnings = logger.Entries.Where(entry => entry.Level == LogLevel.Warning).ToArray();
job.Status.Should().Be(SyncJobStatusEnum.Succeeded);
record.Data.Payload["_raw"]?.ToString().Should().Be(rawPayload);
warnings.Should().ContainSingle();
warnings[0].Message.Should().Contain(system.Id.ToString());
warnings[0].Message.Should().Contain(externalId);
warnings[0].Message.Should().Contain(recordType);
warnings[0].Message.Should().NotContain(rawPayload);
}
private static ExternalSystemEntity CreateSystem(string name, Guid? id = null)
{
var system = new ExternalSystemEntity
{
Data = new SystemData
{
Name = name,
SystemType = ExternalSystemTypeEnum.GenericHttp,
BaseUrl = "https://example.test",
},
};
if (id.HasValue)
{
system.Id = id.Value;
}
return system;
}
private static FetchResult CreateResult(IReadOnlyList<SyncedRecordData> records)
=> new(records.Count, new Dictionary<string, object?>(), records);
private static ServiceProvider CreateServices(IExternalConnector connector)
=> new ServiceCollection()
.AddSingleton(connector)
.AddSingleton<IExternalConnector>(connector)
.BuildServiceProvider();
}