Files
GmRelayBot/tests/GmRelay.Bot.Tests/Features/Landing/DiscordLandingPromisesSmokeTests.cs
T
Toutsu befb2da6a0 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
2026-05-21 15:34:03 +03:00

411 lines
17 KiB
C#

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<string> CallbackData(IReadOnlyList<ActionRowProperties> actionRows) =>
actionRows
.SelectMany(row => row)
.OfType<ButtonProperties>()
.Select(button => button.CustomId)
.OfType<string>()
.ToList();
private static IReadOnlyList<string> CallbackData(InlineKeyboardMarkup markup) =>
markup.InlineKeyboard
.SelectMany(row => row)
.Select(button => button.CallbackData)
.OfType<string>()
.ToList();
private sealed class DiscordLandingSmokeScenario
{
private readonly List<SmokeSession> sessions;
private readonly List<SmokeParticipant> participants = [];
private readonly SessionNotificationMode notificationMode;
private DiscordLandingSmokeScenario(
string title,
IReadOnlyList<DateTimeOffset> 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<SmokeSession> 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<VoteParticipantDto> 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<FakeDiscordMessage> Sends { get; } = [];
public List<FakeDiscordMessage> Edits { get; } = [];
public List<FakeDirectMessage> DirectMessages { get; } = [];
public bool HasPublishedMessage => Sends.Count > 0;
public FakeDiscordMessage LastMessage => Edits.Count > 0 ? Edits[^1] : Sends[^1];
public void PublishBatchMessage(IReadOnlyList<EmbedProperties> embeds, IReadOnlyList<ActionRowProperties> actionRows) =>
Sends.Add(new FakeDiscordMessage(BatchMessageId, embeds, actionRows));
public void EditBatchMessage(IReadOnlyList<EmbedProperties> embeds, IReadOnlyList<ActionRowProperties> 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<EmbedProperties> Embeds,
IReadOnlyList<ActionRowProperties> 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;
}
}