refactor: make session join leave platform-neutral
PR Checks / test-and-build (pull_request) Successful in 5m3s

Convert join/leave interaction commands to PlatformUser, PlatformGroup, and PlatformMessageRef. Persist and look up participants by platform identity while keeping Telegram callbacks intact. Add V017 migration and TDD coverage. Bump version to 2.1.1.
This commit is contained in:
2026-05-18 13:30:48 +03:00
parent cb515b0e05
commit e791fc2f4a
12 changed files with 803 additions and 56 deletions
@@ -0,0 +1,47 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class PlatformNeutralSessionInteractionCommandTests
{
[Fact]
public void JoinSessionCommand_ShouldExposePlatformNeutralInteractionContext()
{
AssertProperty<JoinSessionCommand>("SessionId", typeof(Guid));
AssertProperty<JoinSessionCommand>("User", typeof(PlatformUser));
AssertProperty<JoinSessionCommand>("InteractionId", typeof(string));
AssertProperty<JoinSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<JoinSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertNoTelegramSpecificProperties<JoinSessionCommand>();
}
[Fact]
public void LeaveSessionCommand_ShouldExposePlatformNeutralInteractionContext()
{
AssertProperty<LeaveSessionCommand>("SessionId", typeof(Guid));
AssertProperty<LeaveSessionCommand>("User", typeof(PlatformUser));
AssertProperty<LeaveSessionCommand>("InteractionId", typeof(string));
AssertProperty<LeaveSessionCommand>("Group", typeof(PlatformGroup));
AssertProperty<LeaveSessionCommand>("ScheduleMessage", typeof(PlatformMessageRef));
AssertNoTelegramSpecificProperties<LeaveSessionCommand>();
}
private static void AssertProperty<T>(string name, Type expectedType)
{
var property = Assert.Single(typeof(T).GetProperties(), property => property.Name == name);
Assert.Equal(expectedType, property.PropertyType);
}
private static void AssertNoTelegramSpecificProperties<T>()
{
var names = typeof(T).GetProperties().Select(property => property.Name).ToArray();
Assert.DoesNotContain(names, name => name.Contains("Telegram", StringComparison.Ordinal));
Assert.DoesNotContain("ChatId", names);
Assert.DoesNotContain("MessageId", names);
Assert.DoesNotContain("TelegramUserId", names);
Assert.DoesNotContain("TelegramUsername", names);
}
}
@@ -0,0 +1,61 @@
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class PlatformNeutralSessionInteractionSqlTests
{
[Fact]
public async Task JoinSessionHandler_ShouldPersistPlayersByPlatformIdentity()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
Assert.Contains("platform, external_user_id", handler, StringComparison.Ordinal);
Assert.Contains("ON CONFLICT (platform, external_user_id)", handler, StringComparison.Ordinal);
Assert.Contains("ExternalUserId", handler, StringComparison.Ordinal);
Assert.Contains("ExternalUsername", handler, StringComparison.Ordinal);
Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal);
Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal);
Assert.DoesNotContain("command.TelegramUsername", handler, StringComparison.Ordinal);
}
[Fact]
public async Task LeaveSessionHandler_ShouldFindParticipantsByPlatformIdentity()
{
var handler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
Assert.Contains("p.platform = @Platform", handler, StringComparison.Ordinal);
Assert.Contains("p.external_user_id = @ExternalUserId", handler, StringComparison.Ordinal);
Assert.DoesNotContain("p.telegram_id = @TelegramUserId", handler, StringComparison.Ordinal);
Assert.DoesNotContain("TelegramPlatformIds.", handler, StringComparison.Ordinal);
Assert.DoesNotContain("command.TelegramUserId", handler, StringComparison.Ordinal);
}
[Fact]
public async Task SessionInteractionHandlers_ShouldUpdateSchedulesThroughCommandMessageReference()
{
var joinHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/JoinSessionHandler.cs");
var leaveHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/LeaveSessionHandler.cs");
Assert.Contains("new PlatformScheduleMessage(", joinHandler, StringComparison.Ordinal);
Assert.Contains("command.Group", joinHandler, StringComparison.Ordinal);
Assert.Contains("command.ScheduleMessage", joinHandler, StringComparison.Ordinal);
Assert.Contains("new PlatformScheduleMessage(", leaveHandler, StringComparison.Ordinal);
Assert.Contains("command.Group", leaveHandler, StringComparison.Ordinal);
Assert.Contains("command.ScheduleMessage", leaveHandler, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -99,6 +99,15 @@ public sealed class PlatformIdentityMigrationTests
Assert.Contains("external_username", statsMigration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV017_ShouldAllowPlayersWithoutLegacyTelegramId()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V017__allow_platform_neutral_players.sql");
Assert.Contains("ALTER TABLE players", migration, StringComparison.Ordinal);
Assert.Contains("telegram_id DROP NOT NULL", migration, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);