542f15f2d6
PR Checks / test-and-build (pull_request) Successful in 13m48s
- Extract CreateSessionHandler, ListSessionsHandler, DeleteSessionHandler, ExportCalendarHandler, HandleRescheduleTimeInputHandler, HandleRescheduleVoteHandler to GmRelay.Shared - Add IPlatformMessenger methods: SendScheduleAsync, UpdateScheduleAsync, SendGroupMessageAsync with actions, CreateThreadAsync, DeleteThreadAsync - Rewrite Telegram Bot wrappers as thin adapters delegating to shared handlers - Rewrite DiscordRescheduleVoteHandler to use shared HandleRescheduleVoteHandler - Update UpdateRouter with explicit type aliases for ambiguous handler names - Add contract and source-inspection tests for extracted handlers - Bump version 3.1.1 → 3.2.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
411 lines
17 KiB
C#
411 lines
17 KiB
C#
using GmRelay.Bot.Features.Sessions.CreateSession;
|
|
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
|
|
{
|
|
[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 = 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 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;
|
|
}
|
|
}
|