IqcPostingIdempotencyTests.cs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. using System.Collections.Concurrent;
  2. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Entity;
  3. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Idempotency;
  4. using Xunit;
  5. using Xunit.Abstractions;
  6. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  7. /// <summary>
  8. /// Phase 4A 幂等并发测试(同进程;InMemory store 模拟 tenant+domain+FBILLNO 唯一占位)。
  9. /// **可现在证明**:重复 FBILLNO 行为稳定、四态区分、duplicate-key 回读既有、tenant/domain 隔离。
  10. /// **不能声称**:真实 MySQL DB UNIQUE 实拒 / 事务隔离下竞争(→ Phase 4B)。
  11. /// </summary>
  12. public class IqcPostingIdempotencyTests
  13. {
  14. private readonly ITestOutputHelper _out;
  15. public IqcPostingIdempotencyTests(ITestOutputHelper output) => _out = output;
  16. private const long Tenant = 797403760988229L;
  17. [Fact]
  18. public async Task Claim_1000Concurrent_SameFbillno_ExactlyOneFirstProcess()
  19. {
  20. const int n = 1000;
  21. var svc = new IqcPostingIdempotencyService(new InMemoryIqcPostingStore());
  22. var bag = new ConcurrentBag<IqcPostingClaimOutcome>();
  23. var tasks = Enumerable.Range(0, n).Select(_ => Task.Run(async () =>
  24. {
  25. var r = await svc.ClaimAsync(Tenant, "8010", "FB-CONC-001");
  26. bag.Add(r.Outcome);
  27. }));
  28. await Task.WhenAll(tasks);
  29. var all = bag.ToList();
  30. var first = all.Count(o => o == IqcPostingClaimOutcome.FirstProcess);
  31. var processing = all.Count(o => o == IqcPostingClaimOutcome.Processing);
  32. _out.WriteLine($"[幂等并发] 总={all.Count} FirstProcess={first} Processing={processing} " +
  33. $"AlreadyProcessed={all.Count(o => o == IqcPostingClaimOutcome.AlreadyProcessed)} " +
  34. $"FailedRetryable={all.Count(o => o == IqcPostingClaimOutcome.FailedRetryable)}");
  35. Assert.Equal(n, all.Count);
  36. Assert.Equal(1, first); // 恰好一路首处理 → 不会二次库存写入
  37. Assert.Equal(n - 1, processing); // 其余全部识别为处理中
  38. }
  39. [Fact]
  40. public async Task Claim_SameFbillno_DifferentTenantOrDomain_AllFirstProcess()
  41. {
  42. var svc = new IqcPostingIdempotencyService(new InMemoryIqcPostingStore());
  43. var a = await svc.ClaimAsync(1L, "8010", "FB-1");
  44. var b = await svc.ClaimAsync(2L, "8010", "FB-1"); // 不同租户
  45. var c = await svc.ClaimAsync(1L, "9010", "FB-1"); // 不同域
  46. Assert.Equal(IqcPostingClaimOutcome.FirstProcess, a.Outcome);
  47. Assert.Equal(IqcPostingClaimOutcome.FirstProcess, b.Outcome); // tenant 隔离
  48. Assert.Equal(IqcPostingClaimOutcome.FirstProcess, c.Outcome); // domain 隔离
  49. }
  50. [Fact]
  51. public async Task Claim_AfterPosted_ReturnsAlreadyProcessed()
  52. {
  53. var svc = new IqcPostingIdempotencyService(new InMemoryIqcPostingStore());
  54. var first = await svc.ClaimAsync(Tenant, "8010", "FB-2");
  55. Assert.Equal(IqcPostingClaimOutcome.FirstProcess, first.Outcome);
  56. first.Posting.PostingStatus = "POSTED"; // 模拟过账完成(内存 store 持同引用)
  57. var second = await svc.ClaimAsync(Tenant, "8010", "FB-2");
  58. Assert.Equal(IqcPostingClaimOutcome.AlreadyProcessed, second.Outcome);
  59. Assert.False(second.IsNew);
  60. }
  61. [Fact]
  62. public async Task Claim_AfterFailed_ReturnsFailedRetryable()
  63. {
  64. var svc = new IqcPostingIdempotencyService(new InMemoryIqcPostingStore());
  65. var first = await svc.ClaimAsync(Tenant, "8010", "FB-3");
  66. first.Posting.PostingStatus = "FAILED";
  67. var second = await svc.ClaimAsync(Tenant, "8010", "FB-3");
  68. Assert.Equal(IqcPostingClaimOutcome.FailedRetryable, second.Outcome);
  69. }
  70. [Fact]
  71. public async Task Store_DuplicateInsert_ReadsBackExisting()
  72. {
  73. var store = new InMemoryIqcPostingStore();
  74. var p1 = new AdoIqcInventoryPosting { TenantId = Tenant, DomainCode = "8010", FbillNo = "FB-4", PostingStatus = "PROCESSING" };
  75. var p2 = new AdoIqcInventoryPosting { TenantId = Tenant, DomainCode = "8010", FbillNo = "FB-4", PostingStatus = "PROCESSING" };
  76. var r1 = await store.TryInsertAsync(p1);
  77. var r2 = await store.TryInsertAsync(p2);
  78. Assert.True(r1.Inserted);
  79. Assert.False(r2.Inserted); // 撞唯一
  80. Assert.Same(p1, r2.Existing); // 回读既有(非先查后插)
  81. }
  82. [Fact]
  83. public async Task Claim_EmptyFbillno_Throws()
  84. {
  85. var svc = new IqcPostingIdempotencyService(new InMemoryIqcPostingStore());
  86. await Assert.ThrowsAsync<ArgumentException>(() => svc.ClaimAsync(Tenant, "8010", ""));
  87. }
  88. [Theory]
  89. [InlineData("POSTED", IqcPostingClaimOutcome.AlreadyProcessed)]
  90. [InlineData("PROCESSING", IqcPostingClaimOutcome.Processing)]
  91. [InlineData("PENDING", IqcPostingClaimOutcome.Processing)]
  92. [InlineData("FAILED", IqcPostingClaimOutcome.FailedRetryable)]
  93. [InlineData("weird", IqcPostingClaimOutcome.Processing)]
  94. [InlineData(null, IqcPostingClaimOutcome.Processing)]
  95. public void Classify_MapsStatusToOutcome(string status, IqcPostingClaimOutcome expected)
  96. {
  97. Assert.Equal(expected, IqcPostingIdempotencyService.Classify(status));
  98. }
  99. }