| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116 |
- 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;
- /// <summary>
- /// Phase 4A 幂等并发测试(同进程;InMemory store 模拟 tenant+domain+FBILLNO 唯一占位)。
- /// **可现在证明**:重复 FBILLNO 行为稳定、四态区分、duplicate-key 回读既有、tenant/domain 隔离。
- /// **不能声称**:真实 MySQL DB UNIQUE 实拒 / 事务隔离下竞争(→ Phase 4B)。
- /// </summary>
- 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<IqcPostingClaimOutcome>();
- 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<ArgumentException>(() => 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));
- }
- }
|