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);
}
}
}
}