From befb2da6a03b52bb51c0a1116ebeb1b75dbeb48a Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 21 May 2026 15:31:21 +0300 Subject: [PATCH] test: add DiscordLandingPromisesSmokeTests Mirror TelegramLandingPromisesSmokeTests for Discord MVP: - Join/leave/waitlist promotion via capacity rules - Reschedule voting flow - Direct message notifications on reschedule - Dashboard batch update Issue: #33 --- .../DiscordLandingPromisesSmokeTests.cs | 410 ++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 tests/GmRelay.Bot.Tests/Features/Landing/DiscordLandingPromisesSmokeTests.cs diff --git a/tests/GmRelay.Bot.Tests/Features/Landing/DiscordLandingPromisesSmokeTests.cs b/tests/GmRelay.Bot.Tests/Features/Landing/DiscordLandingPromisesSmokeTests.cs new file mode 100644 index 0000000..8238766 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Landing/DiscordLandingPromisesSmokeTests.cs @@ -0,0 +1,410 @@ +using GmRelay.Bot.Features.Sessions.CreateSession; +using GmRelay.Bot.Features.Sessions.RescheduleSession; +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 +{ + [Fact] + public void Smoke_ShouldCoverDiscordLandingPromisesWithoutExternalDiscordApi() + { + var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero); + var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc); + + Assert.True(parseResult.IsValid); + 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 = HandleRescheduleTimeInputHandler.BuildVotingMessage( + scenario.Title, + scenario.Sessions[0].ScheduledAt, + deadline, + options, + voteParticipants, + []); + var voteKeyboard = HandleRescheduleTimeInputHandler.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 string BuildRecurringSessionCommand() => + string.Join( + '\n', + "/newsession", + "Название: Landing Promise Smoke", + "Время: 15.05.2026 19:30", + "Игр: 3", + "Интервал: 7", + "Мест: 2", + "Ссылка: https://example.test/table"); + + 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( + NewSessionParseResult 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; + } +}