using System.Collections.Concurrent; using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Entity; using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Idempotency; using Xunit; using Xunit.Abstractions; namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse; /// /// Phase 4A 幂等并发测试(同进程;InMemory store 模拟 tenant+domain+FBILLNO 唯一占位)。 /// **可现在证明**:重复 FBILLNO 行为稳定、四态区分、duplicate-key 回读既有、tenant/domain 隔离。 /// **不能声称**:真实 MySQL DB UNIQUE 实拒 / 事务隔离下竞争(→ Phase 4B)。 /// public class IqcPostingIdempotencyTests { private readonly ITestOutputHelper _out; public IqcPostingIdempotencyTests(ITestOutputHelper output) => _out = output; private const long Tenant = 797403760988229L; [Fact] public async Task Claim_1000Concurrent_SameFbillno_ExactlyOneFirstProcess() { const int n = 1000; var svc = new IqcPostingIdempotencyService(new InMemoryIqcPostingStore()); var bag = new ConcurrentBag(); var tasks = Enumerable.Range(0, n).Select(_ => Task.Run(async () => { var r = await svc.ClaimAsync(Tenant, "8010", "FB-CONC-001"); bag.Add(r.Outcome); })); await Task.WhenAll(tasks); var all = bag.ToList(); var first = all.Count(o => o == IqcPostingClaimOutcome.FirstProcess); var processing = all.Count(o => o == IqcPostingClaimOutcome.Processing); _out.WriteLine($"[幂等并发] 总={all.Count} FirstProcess={first} Processing={processing} " + $"AlreadyProcessed={all.Count(o => o == IqcPostingClaimOutcome.AlreadyProcessed)} " + $"FailedRetryable={all.Count(o => o == IqcPostingClaimOutcome.FailedRetryable)}"); Assert.Equal(n, all.Count); Assert.Equal(1, first); // 恰好一路首处理 → 不会二次库存写入 Assert.Equal(n - 1, processing); // 其余全部识别为处理中 } [Fact] public async Task Claim_SameFbillno_DifferentTenantOrDomain_AllFirstProcess() { var svc = new IqcPostingIdempotencyService(new InMemoryIqcPostingStore()); var a = await svc.ClaimAsync(1L, "8010", "FB-1"); var b = await svc.ClaimAsync(2L, "8010", "FB-1"); // 不同租户 var c = await svc.ClaimAsync(1L, "9010", "FB-1"); // 不同域 Assert.Equal(IqcPostingClaimOutcome.FirstProcess, a.Outcome); Assert.Equal(IqcPostingClaimOutcome.FirstProcess, b.Outcome); // tenant 隔离 Assert.Equal(IqcPostingClaimOutcome.FirstProcess, c.Outcome); // domain 隔离 } [Fact] public async Task Claim_AfterPosted_ReturnsAlreadyProcessed() { var svc = new IqcPostingIdempotencyService(new InMemoryIqcPostingStore()); var first = await svc.ClaimAsync(Tenant, "8010", "FB-2"); Assert.Equal(IqcPostingClaimOutcome.FirstProcess, first.Outcome); first.Posting.PostingStatus = "POSTED"; // 模拟过账完成(内存 store 持同引用) var second = await svc.ClaimAsync(Tenant, "8010", "FB-2"); Assert.Equal(IqcPostingClaimOutcome.AlreadyProcessed, second.Outcome); Assert.False(second.IsNew); } [Fact] public async Task Claim_AfterFailed_ReturnsFailedRetryable() { var svc = new IqcPostingIdempotencyService(new InMemoryIqcPostingStore()); var first = await svc.ClaimAsync(Tenant, "8010", "FB-3"); first.Posting.PostingStatus = "FAILED"; var second = await svc.ClaimAsync(Tenant, "8010", "FB-3"); Assert.Equal(IqcPostingClaimOutcome.FailedRetryable, second.Outcome); } [Fact] public async Task Store_DuplicateInsert_ReadsBackExisting() { var store = new InMemoryIqcPostingStore(); var p1 = new AdoIqcInventoryPosting { TenantId = Tenant, DomainCode = "8010", FbillNo = "FB-4", PostingStatus = "PROCESSING" }; var p2 = new AdoIqcInventoryPosting { TenantId = Tenant, DomainCode = "8010", FbillNo = "FB-4", PostingStatus = "PROCESSING" }; var r1 = await store.TryInsertAsync(p1); var r2 = await store.TryInsertAsync(p2); Assert.True(r1.Inserted); Assert.False(r2.Inserted); // 撞唯一 Assert.Same(p1, r2.Existing); // 回读既有(非先查后插) } [Fact] public async Task Claim_EmptyFbillno_Throws() { var svc = new IqcPostingIdempotencyService(new InMemoryIqcPostingStore()); await Assert.ThrowsAsync(() => svc.ClaimAsync(Tenant, "8010", "")); } [Theory] [InlineData("POSTED", IqcPostingClaimOutcome.AlreadyProcessed)] [InlineData("PROCESSING", IqcPostingClaimOutcome.Processing)] [InlineData("PENDING", IqcPostingClaimOutcome.Processing)] [InlineData("FAILED", IqcPostingClaimOutcome.FailedRetryable)] [InlineData("weird", IqcPostingClaimOutcome.Processing)] [InlineData(null, IqcPostingClaimOutcome.Processing)] public void Classify_MapsStatusToOutcome(string status, IqcPostingClaimOutcome expected) { Assert.Equal(expected, IqcPostingIdempotencyService.Classify(status)); } }