using GmRelay.Bot.Features.Sessions.CreateSession; using GmRelay.Bot.Features.Sessions.RescheduleSession; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Rendering; using Telegram.Bot.Types.ReplyMarkups; using GmRelay.Bot.Infrastructure.Telegram; namespace GmRelay.Bot.Tests.Features.Landing; public sealed class TelegramLandingPromisesSmokeTests { [Fact] public void Smoke_ShouldCoverTelegramLandingPromisesWithoutExternalTelegramApi() { 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 = TelegramLandingSmokeScenario.Publish(parseResult, SessionNotificationMode.GroupAndDirect); var publishedCallbacks = CallbackData(scenario.LastMessage.Markup); Assert.Contains("Landing Promise Smoke", scenario.LastMessage.Text); 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(session.ScheduledAt.FormatMoscow(), scenario.LastMessage.Text); }); var firstSessionId = scenario.Sessions[0].Id; var alice = scenario.Join(firstSessionId, 1001, "Alice", "alice"); var bob = scenario.Join(firstSessionId, 1002, "Bob", "bob"); var carol = scenario.Join(firstSessionId, 1003, "Carol", "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)); Assert.Contains("2/2", scenario.LastMessage.Text); Assert.Contains("@alice", scenario.LastMessage.Text); Assert.Contains("@bob", scenario.LastMessage.Text); Assert.Contains("@carol", scenario.LastMessage.Text); scenario.Leave(firstSessionId, alice); Assert.False(scenario.HasParticipant(firstSessionId, alice)); Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, carol)); Assert.DoesNotContain("@alice", scenario.LastMessage.Text); Assert.Contains("@carol", scenario.LastMessage.Text); 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(RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, bob)); Assert.Equal(RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, carol)); Assert.Contains(options[1].ProposedAt.UtcDateTime.FormatMoscow(), scenario.LastMessage.Text); Assert.Collection( scenario.Messenger.DirectMessages.Select(message => message.TelegramId).Order(), telegramId => Assert.Equal(1002, telegramId), telegramId => Assert.Equal(1003, telegramId)); 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("Landing Promise Smoke - Dashboard Sync", scenario.LastMessage.Text); Assert.Contains("@bob", scenario.LastMessage.Text); Assert.Contains("@carol", scenario.LastMessage.Text); } private static string BuildRecurringSessionCommand() => string.Join( '\n', "/newsession", "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435: Landing Promise Smoke", "\u0412\u0440\u0435\u043c\u044f: 15.05.2026 19:30", "\u0418\u0433\u0440: 3", "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b: 7", "\u041c\u0435\u0441\u0442: 2", "\u0421\u0441\u044b\u043b\u043a\u0430: https://example.test/table"); private static IReadOnlyList CallbackData(InlineKeyboardMarkup markup) => markup.InlineKeyboard .SelectMany(row => row) .Select(button => button.CallbackData) .OfType() .ToList(); private sealed class TelegramLandingSmokeScenario { private readonly List sessions; private readonly List participants = []; private readonly SessionNotificationMode notificationMode; private TelegramLandingSmokeScenario( 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 FakeTelegramMessenger Messenger { get; } = new(); public IReadOnlyList Sessions => sessions; public FakeTelegramMessage LastMessage => Messenger.LastMessage; public static TelegramLandingSmokeScenario Publish( NewSessionParseResult parseResult, SessionNotificationMode notificationMode) { var scenario = new TelegramLandingSmokeScenario( parseResult.Title!, parseResult.ScheduledTimes, parseResult.MaxPlayers, parseResult.Link!, notificationMode); scenario.RenderBatch(); return scenario; } public Guid Join(Guid sessionId, long telegramId, string displayName, string? telegramUsername) { 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, telegramId, displayName, telegramUsername, 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, value.TelegramUsername, value.TelegramId)) .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.TelegramId, 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, participant.TelegramUsername, participant.RegistrationStatus)) .ToList()); var renderResult = TelegramSessionBatchRenderer.Render(view); if (Messenger.HasPublishedMessage) { Messenger.EditBatchMessage(renderResult.Text, renderResult.Markup); } else { Messenger.PublishBatchMessage(renderResult.Text, renderResult.Markup); } } } private sealed class FakeTelegramMessenger { private const int BatchMessageId = 7001; public List Sends { get; } = []; public List Edits { get; } = []; public List DirectMessages { get; } = []; public bool HasPublishedMessage => Sends.Count > 0; public FakeTelegramMessage LastMessage => Edits.Count > 0 ? Edits[^1] : Sends[^1]; public void PublishBatchMessage(string text, InlineKeyboardMarkup markup) => Sends.Add(new FakeTelegramMessage(BatchMessageId, text, markup)); public void EditBatchMessage(string text, InlineKeyboardMarkup markup) => Edits.Add(new FakeTelegramMessage(BatchMessageId, text, markup)); public void SendDirectMessage(long telegramId, string text) => DirectMessages.Add(new FakeDirectMessage(telegramId, text)); } private sealed record FakeTelegramMessage( int MessageId, string Text, InlineKeyboardMarkup Markup); private sealed record FakeDirectMessage(long TelegramId, 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, long TelegramId, string DisplayName, string? TelegramUsername, string RegistrationStatus, string RsvpStatus, int JoinOrder) { public string RegistrationStatus { get; set; } = RegistrationStatus; public string RsvpStatus { get; set; } = RsvpStatus; } }