From b544d5cccd76735e034c8055918a50927b437474 Mon Sep 17 00:00:00 2001
From: wenxiangchao <wenxiangchao@ihengxin.com.cn>
Date: 星期二, 10 十一月 2026 08:29:04 +0800
Subject: [PATCH] fix: 维护模式重复进入失败与按钮灰死 + Broker 并发验证用例

---
 tests/Aging.MvpTests/DbLeaseStoreTests.cs |  160 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 160 insertions(+), 0 deletions(-)

diff --git a/tests/Aging.MvpTests/DbLeaseStoreTests.cs b/tests/Aging.MvpTests/DbLeaseStoreTests.cs
new file mode 100644
index 0000000..ef1b2d0
--- /dev/null
+++ b/tests/Aging.MvpTests/DbLeaseStoreTests.cs
@@ -0,0 +1,160 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Aging.Infrastructure.Core;
+using Aging.ResourceContracts;
+using Xunit;
+
+namespace Aging.MvpTests
+{
+    public class DbLeaseStoreTests : IDisposable
+    {
+        private readonly string _dbPath;
+        private readonly DbLeaseStore _store;
+
+        public DbLeaseStoreTests()
+        {
+            _dbPath = Path.Combine(Path.GetTempPath(),
+                "arb_test_" + Guid.NewGuid().ToString("N") + ".db");
+            _store = new DbLeaseStore(_dbPath, "local-main");
+        }
+        public void Dispose() { try { File.Delete(_dbPath); File.Delete(_dbPath + "-wal"); File.Delete(_dbPath + "-shm"); } catch { } }
+
+        private AcquireRequest Req(string key, OwnerTypeV3 type = OwnerTypeV3.Maintenance,
+            int ttl = 30) => new AcquireRequest
+        {
+            OwnerId = "test-owner", OwnerType = type,
+            ProcessInstanceId = Guid.NewGuid().ToString("N"),
+            ResourceKeys = new[] { key }, TtlSeconds = ttl
+        };
+
+        [Fact]
+        public void Acquire_FirstOwner_Succeeds()
+        {
+            var r = _store.Acquire(Req("can/can02"));
+            Assert.Equal(AcquireResultCode.Acquired, r.Code);
+            Assert.Equal(1, r.Grants[0].Generation);
+        }
+
+        [Fact]
+        public void Acquire_WhileActiveByOther_ReturnsConflictWithHolder()
+        {
+            _store.Acquire(Req("can/can02"));
+            var second = new AcquireRequest
+            {
+                OwnerId = "other-owner", OwnerType = OwnerTypeV3.Production,
+                ProcessInstanceId = "x", ResourceKeys = new[] { "can/can02" }
+            };
+            var r = _store.Acquire(second);
+            Assert.Equal(AcquireResultCode.ConflictHeld, r.Code);
+            Assert.Equal("test-owner", r.Holder.OwnerId);
+        }
+
+        [Fact]
+        public void TwoIndependentStores_SameFile_SimulatesTwoProcesses()
+        {
+            var storeB = new DbLeaseStore(_dbPath, "local-main");
+            var a = _store.Acquire(Req("psu/psu01/ch/5"));
+            var b = storeB.Acquire(new AcquireRequest
+            {
+                OwnerId = "W02", OwnerType = OwnerTypeV3.Production,
+                ProcessInstanceId = "p2", ResourceKeys = new[] { "psu/psu01/ch/5" }
+            });
+            Assert.Equal(AcquireResultCode.Acquired, a.Code);
+            Assert.Equal(AcquireResultCode.ConflictHeld, b.Code);
+        }
+
+        [Fact]
+        public void Concurrent_50Threads_OnlyOneWins()
+        {
+            int winners = 0;
+            Parallel.For(0, 50, _ =>
+            {
+                var s = new DbLeaseStore(_dbPath, "local-main");
+                var r = s.Acquire(new AcquireRequest
+                {
+                    OwnerId = Guid.NewGuid().ToString("N"), OwnerType = OwnerTypeV3.Production,
+                    ProcessInstanceId = "p", ResourceKeys = new[] { "can/bus" }, WaitTimeoutMs = 3000
+                });
+                if (r.Code == AcquireResultCode.Acquired) Interlocked.Increment(ref winners);
+            });
+            Assert.Equal(1, winners);
+        }
+
+        [Fact]
+        public void ExpiredLease_EntersRecoveryRequired_NotAutoReclaim()
+        {
+            _store.Acquire(Req("can/can02", ttl: -1));
+            _store.SweepExpired();
+            var r = _store.Acquire(Req("can/can02", OwnerTypeV3.Production));
+            Assert.Equal(AcquireResultCode.RecoveryBlocked, r.Code);
+
+            Assert.True(_store.ApproveRecovery("can/can02", "{\"output\":\"off\"}", "admin"));
+            var again = _store.Acquire(Req("can/can02", OwnerTypeV3.Production));
+            Assert.Equal(AcquireResultCode.Acquired, again.Code);
+            Assert.Equal(2, again.Grants[0].Generation);
+        }
+
+        [Fact]
+        public void Renew_WithStaleGeneration_Fails()
+        {
+            var g = _store.Acquire(Req("can/can02")).Grants[0];
+            var stale = new LeaseGrant(g.AuthorityId, g.ResourceKey, g.LeaseId,
+                g.OwnerId, g.OwnerType, g.Epoch, 999, g.ExpiresUtcMs);
+            Assert.False(_store.Renew(stale, 30));
+            Assert.True(_store.Renew(g, 30));
+        }
+
+        [Fact]
+        public void BumpEpoch_InvalidatesAllActiveGrants()
+        {
+            var g = _store.Acquire(Req("can/can02")).Grants[0];
+            _store.BumpEpochForTest("arb.db 閲嶅惎妯℃嫙");
+            Assert.False(_store.Renew(g, 30));
+        }
+
+        [Fact]
+        public void GroupAcquire_PartialConflict_RollsBackAll()
+        {
+            _store.Acquire(Req("psu/b"));
+            var r = _store.Acquire(new AcquireRequest
+            {
+                OwnerId = "W09", OwnerType = OwnerTypeV3.Production, ProcessInstanceId = "p",
+                ResourceKeys = new[] { "psu/a", "psu/b", "psu/c" }
+            });
+            Assert.Equal(AcquireResultCode.ConflictHeld, r.Code);
+            Assert.Null(_store.ProbeConflict("psu/a"));
+        }
+
+        [Fact]
+        public void ExpiredLease_ReconcileAfterBoot_FreesResource_ForNewAcquire()
+        {
+            // 妯℃嫙鏃� owner 杩涚▼鏈噴鏀惧氨寮傚父閫�鍑猴細鐭� TTL 绉熺害娉勬紡鍚庤繃鏈�
+            var leaked = _store.Acquire(Req("can/can99", OwnerTypeV3.Maintenance, ttl: 1));
+            Assert.Equal(AcquireResultCode.Acquired, leaked.Code);
+            Thread.Sleep(1600);
+
+            // 鏈璐﹀墠锛氳繃鏈熺绾﹁繘鍏� RecoveryBlocked锛堟案涔呴樆濉炵幇鐘讹級
+            var blocked = _store.Acquire(new AcquireRequest
+            {
+                OwnerId = "new-owner", OwnerType = OwnerTypeV3.Maintenance,
+                ProcessInstanceId = "y", ResourceKeys = new[] { "can/can99" }
+            });
+            Assert.Equal(AcquireResultCode.RecoveryBlocked, blocked.Code);
+
+            // 鍚姩瀵硅处鑷剤锛圛NV-03 鑷姩鍖栧舰寮忥紝璇佹嵁钀� recovery_checkpoint锛�
+            Assert.True(_store.ReconcileAfterBoot("HOST/999") >= 1);
+            Assert.Equal(LeasePhase.Free, _store.PhaseOf("can/can99"));
+
+            // 瀵硅处鍚庡彲閲嶆柊鑾峰彇锛屼笖 Generation 鍙墠杩涗笉鍥為��
+            var reacquired = _store.Acquire(new AcquireRequest
+            {
+                OwnerId = "new-owner", OwnerType = OwnerTypeV3.Maintenance,
+                ProcessInstanceId = "y", ResourceKeys = new[] { "can/can99" }
+            });
+            Assert.Equal(AcquireResultCode.Acquired, reacquired.Code);
+            Assert.True(reacquired.Grants[0].Generation > leaked.Grants[0].Generation);
+        }
+    }
+}

--
Gitblit v1.9.1