using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler; using GmRelay.DiscordBot.Rendering; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Rendering; using NetCord.Rest; using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Tests.Features.Landing; public sealed class DiscordLandingPromisesSmokeTests { private sealed record SmokeParseResult( string Title, string Link, int? MaxPlayers, IReadOnlyList ScheduledTimes); [Fact] public void Smoke_ShouldCoverDiscordLandingPromisesWithoutExternalDiscordApi() { var parseResult = BuildRecurringSessionParseResult(); Assert.Equal(3, parseResult.ScheduledTimes.Count); Assert.Equal(2, parseResult.MaxPlayers); Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]); var scenario = DiscordLandingSmokeScenario.Publish(parseResult, SessionNotificationMode.GroupAndDirect); var publishedCallbacks = CallbackData(scenario.LastMessage.ActionRows); Assert.Contains(scenario.LastMessage.Embeds, e => e.Title!.Contains("Landing Promise Smoke")); Assert.Equal(6, publishedCallbacks.Count); Assert.All(scenario.Sessions, session => { Assert.Contains($"join_session:{session.Id}", publishedCallbacks); Assert.Contains($"leave_session:{session.Id}", publishedCallbacks); Assert.Contains(scenario.LastMessage.Embeds, embed => embed.Title!.Contains(session.ScheduledAt.FormatMoscow())); }); var firstSessionId = scenario.Sessions[0].Id; var alice = scenario.Join(firstSessionId, 1001UL, "Alice"); var bob = scenario.Join(firstSessionId, 1002UL, "Bob"); var carol = scenario.Join(firstSessionId, 1003UL, "Carol"); Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, alice)); Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, bob)); Assert.Equal(ParticipantRegistrationStatus.Waitlisted, scenario.RegistrationStatus(firstSessionId, carol)); var firstSessionEmbed = scenario.LastMessage.Embeds.First(e => e.Title!.Contains(scenario.Sessions[0].ScheduledAt.FormatMoscow())); Assert.Equal("2/2", firstSessionEmbed.Fields!.First().Value); Assert.Contains("Alice", firstSessionEmbed.Description); Assert.Contains("Bob", firstSessionEmbed.Description); Assert.Contains("Carol", firstSessionEmbed.Description); scenario.Leave(firstSessionId, alice); Assert.False(scenario.HasParticipant(firstSessionId, alice)); Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, carol)); firstSessionEmbed = scenario.LastMessage.Embeds.First(e => e.Title!.Contains(scenario.Sessions[0].ScheduledAt.FormatMoscow())); Assert.DoesNotContain("Alice", firstSessionEmbed.Description); Assert.Contains("Carol", firstSessionEmbed.Description); scenario.MarkRsvpConfirmed(firstSessionId, bob); scenario.MarkRsvpConfirmed(firstSessionId, carol); var option1Id = Guid.NewGuid(); var option2Id = Guid.NewGuid(); var options = new[] { new RescheduleOptionDto(option1Id, 1, new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero)), new RescheduleOptionDto(option2Id, 2, new DateTimeOffset(2026, 5, 30, 15, 0, 0, TimeSpan.Zero)) }; var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero); var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId); var voteMessage = BotRescheduleHandler.BuildVotingMessage( scenario.Title, scenario.Sessions[0].ScheduledAt, deadline, options, voteParticipants, []); var voteKeyboard = BotRescheduleHandler.BuildVotingKeyboard(options); Assert.Contains("Landing Promise Smoke", voteMessage); Assert.Contains("0/2", voteMessage); Assert.Contains($"reschedule_vote:{option1Id}", CallbackData(voteKeyboard)); Assert.Contains($"reschedule_vote:{option2Id}", CallbackData(voteKeyboard)); var votes = voteParticipants .Select(participant => new RescheduleOptionVoteDto( option2Id, participant.PlayerId, participant.DisplayName, participant.TelegramUsername)) .ToList(); var decision = RescheduleVoteRules.SelectWinner( options.Select(option => new RescheduleOptionVoteCount( option.OptionId, votes.Count(vote => vote.OptionId == option.OptionId))).ToList()); Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome); Assert.Equal(option2Id, decision.SelectedOptionId); scenario.ApplyReschedule(firstSessionId, options[1].ProposedAt); Assert.Equal(options[1].ProposedAt.UtcDateTime, scenario.Sessions[0].ScheduledAt); Assert.Equal(GmRelay.Shared.Domain.RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, bob)); Assert.Equal(GmRelay.Shared.Domain.RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, carol)); Assert.Contains(scenario.LastMessage.Embeds, e => e.Title!.Contains(options[1].ProposedAt.UtcDateTime.FormatMoscow())); Assert.Collection( scenario.Messenger.DirectMessages.Select(message => message.DiscordId).Order(), discordId => Assert.Equal(1002UL, discordId), discordId => Assert.Equal(1003UL, discordId)); Assert.All(scenario.Messenger.DirectMessages, message => { Assert.Contains("Landing Promise Smoke", message.Text); Assert.Contains(options[1].ProposedAt.UtcDateTime.FormatMoscow(), message.Text); }); var editsBeforeDashboardUpdate = scenario.Messenger.Edits.Count; scenario.UpdateBatchFromDashboard("Landing Promise Smoke - Dashboard Sync"); Assert.True(scenario.Messenger.Edits.Count > editsBeforeDashboardUpdate); Assert.Contains(scenario.LastMessage.Embeds, e => e.Title!.Contains("Landing Promise Smoke - Dashboard Sync")); firstSessionEmbed = scenario.LastMessage.Embeds.First(e => e.Title!.Contains(scenario.Sessions[0].ScheduledAt.FormatMoscow())); Assert.Contains("Bob", firstSessionEmbed.Description); Assert.Contains("Carol", firstSessionEmbed.Description); } private static SmokeParseResult BuildRecurringSessionParseResult() => new( Title: "Landing Promise Smoke", Link: "https://example.test/table", MaxPlayers: 2, ScheduledTimes: new[] { new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero), new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero), new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero), }); private static IReadOnlyList CallbackData(IReadOnlyList actionRows) => actionRows .SelectMany(row => row) .OfType() .Select(button => button.CustomId) .OfType() .ToList(); private static IReadOnlyList CallbackData(InlineKeyboardMarkup markup) => markup.InlineKeyboard .SelectMany(row => row) .Select(button => button.CallbackData) .OfType() .ToList(); private sealed class DiscordLandingSmokeScenario { private readonly List sessions; private readonly List participants = []; private readonly SessionNotificationMode notificationMode; private DiscordLandingSmokeScenario( string title, IReadOnlyList scheduledTimes, int? maxPlayers, string joinLink, SessionNotificationMode notificationMode) { Title = title; this.notificationMode = notificationMode; sessions = scheduledTimes .Select(scheduledAt => new SmokeSession( Guid.NewGuid(), scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink)) .ToList(); } public string Title { get; private set; } public FakeDiscordMessenger Messenger { get; } = new(); public IReadOnlyList Sessions => sessions; public FakeDiscordMessage LastMessage => Messenger.LastMessage; public static DiscordLandingSmokeScenario Publish( SmokeParseResult parseResult, SessionNotificationMode notificationMode) { var scenario = new DiscordLandingSmokeScenario( parseResult.Title, parseResult.ScheduledTimes, parseResult.MaxPlayers, parseResult.Link, notificationMode); scenario.RenderBatch(); return scenario; } public Guid Join(Guid sessionId, ulong discordId, string displayName) { var session = sessions.Single(value => value.Id == sessionId); var activeParticipants = participants.Count(participant => participant.SessionId == sessionId && participant.RegistrationStatus == ParticipantRegistrationStatus.Active); var registrationStatus = SessionCapacityRules.DecideJoinStatus( session.MaxPlayers, activeParticipants); var playerId = Guid.NewGuid(); participants.Add(new SmokeParticipant( sessionId, playerId, discordId, displayName, registrationStatus, GmRelay.Shared.Domain.RsvpStatus.Pending, participants.Count)); RenderBatch(); return playerId; } public void Leave(Guid sessionId, Guid playerId) { var participant = participants.Single(value => value.SessionId == sessionId && value.PlayerId == playerId); participants.Remove(participant); var session = sessions.Single(value => value.Id == sessionId); var activeParticipantsAfterLeave = participants.Count(value => value.SessionId == sessionId && value.RegistrationStatus == ParticipantRegistrationStatus.Active); var waitlistedParticipants = participants.Count(value => value.SessionId == sessionId && value.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted); if (SessionCapacityRules.ShouldPromoteAfterParticipantLeaves( participant.RegistrationStatus, session.MaxPlayers, activeParticipantsAfterLeave, waitlistedParticipants)) { var promoted = participants .Where(value => value.SessionId == sessionId && value.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted) .OrderBy(value => value.JoinOrder) .First(); promoted.RegistrationStatus = ParticipantRegistrationStatus.Active; promoted.RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Pending; } RenderBatch(); } public bool HasParticipant(Guid sessionId, Guid playerId) => participants.Any(value => value.SessionId == sessionId && value.PlayerId == playerId); public string RegistrationStatus(Guid sessionId, Guid playerId) => participants.Single(value => value.SessionId == sessionId && value.PlayerId == playerId).RegistrationStatus; public string RsvpStatus(Guid sessionId, Guid playerId) => participants.Single(value => value.SessionId == sessionId && value.PlayerId == playerId).RsvpStatus; public void MarkRsvpConfirmed(Guid sessionId, Guid playerId) { participants.Single(value => value.SessionId == sessionId && value.PlayerId == playerId).RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Confirmed; } public IReadOnlyList ActiveVoteParticipants(Guid sessionId) => participants .Where(value => value.SessionId == sessionId && value.RegistrationStatus == ParticipantRegistrationStatus.Active) .OrderBy(value => value.DisplayName) .Select(value => new VoteParticipantDto( value.PlayerId, value.DisplayName, null, 0)) .ToList(); public void ApplyReschedule(Guid sessionId, DateTimeOffset newScheduledAt) { sessions.Single(value => value.Id == sessionId).ScheduledAt = newScheduledAt.UtcDateTime; foreach (var participant in participants.Where(value => value.SessionId == sessionId && value.RegistrationStatus == ParticipantRegistrationStatus.Active)) { participant.RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Pending; } RenderBatch(); if (!notificationMode.ShouldSendDirectMessages()) { return; } var notification = $""" Reschedule approved {Title} {newScheduledAt.UtcDateTime.FormatMoscow()} """; foreach (var participant in participants.Where(value => value.SessionId == sessionId && value.RegistrationStatus == ParticipantRegistrationStatus.Active)) { Messenger.SendDirectMessage(participant.DiscordId, notification); } } public void UpdateBatchFromDashboard(string title) { Title = title; RenderBatch(); } private void RenderBatch() { var view = SessionBatchViewBuilder.Build( Title, sessions .Select(session => new SessionBatchDto( session.Id, session.ScheduledAt, session.Status, session.MaxPlayers, session.JoinLink)) .ToList(), participants .Select(participant => new ParticipantBatchDto( participant.SessionId, participant.DisplayName, null, participant.RegistrationStatus)) .ToList()); var renderResult = DiscordSessionBatchRenderer.Render(view); if (Messenger.HasPublishedMessage) { Messenger.EditBatchMessage(renderResult.Embeds, renderResult.ActionRows); } else { Messenger.PublishBatchMessage(renderResult.Embeds, renderResult.ActionRows); } } } private sealed class FakeDiscordMessenger { private const int BatchMessageId = 7001; public List Sends { get; } = []; public List Edits { get; } = []; public List DirectMessages { get; } = []; public bool HasPublishedMessage => Sends.Count > 0; public FakeDiscordMessage LastMessage => Edits.Count > 0 ? Edits[^1] : Sends[^1]; public void PublishBatchMessage(IReadOnlyList embeds, IReadOnlyList actionRows) => Sends.Add(new FakeDiscordMessage(BatchMessageId, embeds, actionRows)); public void EditBatchMessage(IReadOnlyList embeds, IReadOnlyList actionRows) => Edits.Add(new FakeDiscordMessage(BatchMessageId, embeds, actionRows)); public void SendDirectMessage(ulong discordId, string text) => DirectMessages.Add(new FakeDirectMessage(discordId, text)); } private sealed record FakeDiscordMessage( int MessageId, IReadOnlyList Embeds, IReadOnlyList ActionRows); private sealed record FakeDirectMessage(ulong DiscordId, string Text); private sealed record SmokeSession( Guid Id, DateTime ScheduledAt, string Status, int? MaxPlayers, string JoinLink) { public DateTime ScheduledAt { get; set; } = ScheduledAt; } private sealed record SmokeParticipant( Guid SessionId, Guid PlayerId, ulong DiscordId, string DisplayName, string RegistrationStatus, string RsvpStatus, int JoinOrder) { public string RegistrationStatus { get; set; } = RegistrationStatus; public string RsvpStatus { get; set; } = RsvpStatus; } }