using System; using System.Linq; using System.Threading.Tasks; using GameFrameX.Capability.Online.Abstractions.Contracts; using GameFrameX.Capability.Online.Abstractions.Events; using GameFrameX.Capability.Online.Abstractions.Scope; using GameFrameX.Capability.Online.Foundation.Session; using GameFrameX.Capability.Online.Abstractions.Tokens; namespace GameFrameX.Tests.Online { /// /// 会话恢复服务测试:登录签发(LatestWins 顶替)、新连接恢复(验证 + 连接替换 + 原子轮换)、 /// 重放 / 过期 / 错主体拒绝、并发恢复互斥与断线开窗。 /// public class OnlineSessionReconnectServiceTests { /// 测试用租户标识。 private const long TenantId = 1; /// 测试用应用标识。 private const long AppId = 10; /// 测试用区服标识。 private const long ServerId = 100; /// 测试用玩家标识。 private const long PlayerId = 1001; /// 另一玩家标识(错主体用例)。 private const long OtherPlayerId = 1002; [Fact] public async Task IssueForLogin_ShouldReturnCredentialAndKickOldActiveSession() { var harness = new Harness(); var first = await harness.IssueLoginAsync("conn-1"); Assert.NotEmpty(first.Token); var second = await harness.IssueLoginAsync("conn-2"); Assert.NotEmpty(second.Token); Assert.NotEqual(first.Token, second.Token); // LatestWins:旧会话被顶替(Kicked + ReplacedByNewSession),旧 Token 失效。 var oldValidation = await harness.Tokens.ValidateTokenAsync(first.Token); Assert.False(oldValidation.IsSuccess); Assert.Equal(OnlineErrorCode.TokenRevoked, oldValidation.Code); var kicked = await harness.Store.FindAsync(first.SessionId); Assert.Equal(OnlineSessionState.Kicked, kicked.State); Assert.Equal(OnlineSessionCloseReason.ReplacedByNewSession, kicked.CloseReason); // 新会话随签发直接落 Connected 并绑定当前连接。 var connected = await harness.Store.FindAsync(second.SessionId); Assert.Equal(OnlineSessionState.Connected, connected.State); Assert.Equal("conn-2", connected.ConnectionId); } [Fact] public async Task Resume_WithinWindow_ShouldRotateTokenAndReplaceConnection() { var harness = new Harness(); var issued = await harness.IssueLoginAsync("conn-1"); var marked = await harness.Service.NotifyPlayerDisconnectedAsync(harness.PlayerScope(), 30); Assert.True(marked.IsSuccess); var duringWindow = await harness.Store.FindAsync(issued.SessionId); Assert.Equal(OnlineSessionState.Reconnecting, duringWindow.State); var resumed = await harness.Service.ResumeAsync(harness.ScopeWithPlayer(0), issued.Token, "conn-2"); Assert.True(resumed.IsSuccess, resumed.Message); Assert.Equal(PlayerId, resumed.Data.PlayerId); Assert.NotEqual(issued.Token, resumed.Data.NewToken); var after = await harness.Store.FindAsync(issued.SessionId); Assert.Equal(OnlineSessionState.Connected, after.State); Assert.Equal("conn-2", after.ConnectionId); Assert.Equal(0, after.ReconnectDeadlineAtTime); // 旧凭据即刻失效(原子消费):重放判 TokenRevoked。 var replay = await harness.Service.ResumeAsync(harness.ScopeWithPlayer(0), issued.Token, "conn-3"); Assert.False(replay.IsSuccess); Assert.Equal(OnlineErrorCode.TokenRevoked, replay.Code); } [Fact] public async Task Resume_ExpiredToken_ShouldBeRejected() { var harness = new Harness(tokenTimeToLiveSeconds: 1); var issued = await harness.IssueLoginAsync("conn-1"); // 有效期 1 秒,基准时刻推到 2 秒后(经存储内会话的到期时刻直接改写模拟)。 var session = await harness.Store.FindAsync(issued.SessionId); session.TokenExpiresAtTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - 2000; await harness.Store.AddOrUpdateAsync(session); var resumed = await harness.Service.ResumeAsync(harness.ScopeWithPlayer(0), issued.Token, "conn-2"); Assert.False(resumed.IsSuccess); Assert.Equal(OnlineErrorCode.TokenExpired, resumed.Code); } [Fact] public async Task Resume_WrongPlayerScope_ShouldBeRejected() { var harness = new Harness(); var issued = await harness.IssueLoginAsync("conn-1"); // 已绑定玩家位的连接拿别人的凭据恢复 → 错主体拒绝。 var resumed = await harness.Service.ResumeAsync(harness.ScopeWithPlayer(OtherPlayerId), issued.Token, "conn-2"); Assert.False(resumed.IsSuccess); Assert.Equal(OnlineErrorCode.ScopeDenied, resumed.Code); } [Fact] public async Task Resume_TerminalSession_ShouldBeRejected() { var harness = new Harness(); var issued = await harness.IssueLoginAsync("conn-1"); var kicked = await harness.Tokens.KickAsync(new OnlineTokenKickRequest { SessionId = issued.SessionId, Scope = harness.PlayerScope(), Reason = "test" }); Assert.True(kicked.Kicked); var resumed = await harness.Service.ResumeAsync(harness.ScopeWithPlayer(0), issued.Token, "conn-2"); Assert.False(resumed.IsSuccess); // 顶号 / 踢线清空指纹:凭据按指纹不可寻址 → TokenRevoked(2xxx 段显式拒绝)。 Assert.Equal(OnlineErrorCode.TokenRevoked, resumed.Code); } [Fact] public async Task Resume_ConcurrentlyWithSameToken_ShouldAdmitExactlyOne() { var harness = new Harness(); var issued = await harness.IssueLoginAsync("conn-1"); // 真并发重复恢复:同一 Token 恒定同分片互斥——后到者在验证步见旧指纹已轮换,恰一成功,不产生双活凭据。 var results = await Task.WhenAll( harness.Service.ResumeAsync(harness.ScopeWithPlayer(0), issued.Token, "conn-a"), harness.Service.ResumeAsync(harness.ScopeWithPlayer(0), issued.Token, "conn-b"), harness.Service.ResumeAsync(harness.ScopeWithPlayer(0), issued.Token, "conn-c")); var successes = results.Count(result => result.IsSuccess); Assert.Equal(1, successes); Assert.All(results.Where(result => !result.IsSuccess), result => Assert.Equal(OnlineErrorCode.TokenRevoked, result.Code)); } [Fact] public async Task Resume_FromDifferentPlayers_ShouldRunInParallelWithoutGlobalQueueing() { var store = new BlockingOnlineSessionStore(); var harness = new Harness(store); var first = await harness.IssueLoginAsync("conn-1"); var second = await harness.IssueLoginAsync(OtherPlayerId, "conn-2"); var firstMarked = await harness.Service.NotifyPlayerDisconnectedAsync(harness.PlayerScope(), 30); Assert.True(firstMarked.IsSuccess, firstMarked.Message); var secondMarked = await harness.Service.NotifyPlayerDisconnectedAsync(harness.ScopeWithPlayer(OtherPlayerId), 30); Assert.True(secondMarked.IsSuccess, secondMarked.Message); // 布防:玩家 A 的会话写悬挂(恢复卡在分片门内的存储写上),玩家 B 的恢复不得排队等 A—— // 全局单门时代 B 会等到超时(GFX-1078 M56 回归锚点,旧码红 / 新码绿)。 store.BlockSessionWrites(first.SessionId); var suspendedResume = harness.Service.ResumeAsync(harness.ScopeWithPlayer(0), first.Token, "conn-a"); await store.WaitUntilBlockedAsync(); var otherResume = harness.Service.ResumeAsync(harness.ScopeWithPlayer(OtherPlayerId), second.Token, "conn-b"); var finished = await Task.WhenAny(otherResume, Task.Delay(TimeSpan.FromSeconds(5))); Assert.Same(otherResume, finished); var otherOutcome = await otherResume; Assert.True(otherOutcome.IsSuccess, otherOutcome.Message); store.ReleaseBlockedWrites(); var firstOutcome = await suspendedResume; Assert.True(firstOutcome.IsSuccess, firstOutcome.Message); Assert.Equal(PlayerId, firstOutcome.Data.PlayerId); Assert.Equal(OtherPlayerId, otherOutcome.Data.PlayerId); } [Fact] public async Task NotifyPlayerDisconnected_NoActiveSession_ShouldSucceedSilently() { var harness = new Harness(); var notified = await harness.Service.NotifyPlayerDisconnectedAsync(harness.PlayerScope(), 30); Assert.True(notified.IsSuccess); } /// /// 测试基座:可注入存储(缺省内存实现)+ 令牌 / 会话 / 恢复三服务。 /// private sealed class Harness { public Harness(IOnlineSessionStore store = null, int tokenTimeToLiveSeconds = OnlineSessionReconnectService.DefaultTokenTimeToLiveSeconds) { Recorder = new OnlineEventRecorder(); Store = store ?? new InMemoryOnlineSessionStore(); Tokens = new OnlineSessionTokenService(Store, Recorder); Sessions = new OnlineSessionManager(Store, Recorder); Service = new OnlineSessionReconnectService(Tokens, Sessions, Store, tokenTimeToLiveSeconds); } public OnlineEventRecorder Recorder { get; } public IOnlineSessionStore Store { get; } public OnlineSessionTokenService Tokens { get; } public OnlineSessionManager Sessions { get; } public OnlineSessionReconnectService Service { get; } public async Task IssueLoginAsync(string connectionId) { var issued = await Service.IssueForLoginAsync(PlayerScope(), connectionId); Assert.True(issued.IsSuccess, issued.Message); return issued.Data; } public async Task IssueLoginAsync(long playerId, string connectionId) { var issued = await Service.IssueForLoginAsync(ScopeWithPlayer(playerId), connectionId); Assert.True(issued.IsSuccess, issued.Message); return issued.Data; } public OnlineScope PlayerScope() { return new OnlineScope(TenantId, AppId, ServerId, PlayerId); } public OnlineScope ScopeWithPlayer(long playerId) { return new OnlineScope(TenantId, AppId, ServerId, playerId); } } } }