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>
399 lines
16 KiB
C#
399 lines
16 KiB
C#
using GmRelay.Bot.Features.Sessions.CreateSession;
|
|
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
|
|
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 = 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(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<string> CallbackData(InlineKeyboardMarkup markup) =>
|
|
markup.InlineKeyboard
|
|
.SelectMany(row => row)
|
|
.Select(button => button.CallbackData)
|
|
.OfType<string>()
|
|
.ToList();
|
|
|
|
private sealed class TelegramLandingSmokeScenario
|
|
{
|
|
private readonly List<SmokeSession> sessions;
|
|
private readonly List<SmokeParticipant> participants = [];
|
|
private readonly SessionNotificationMode notificationMode;
|
|
|
|
private TelegramLandingSmokeScenario(
|
|
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 FakeTelegramMessenger Messenger { get; } = new();
|
|
public IReadOnlyList<SmokeSession> 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<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,
|
|
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<FakeTelegramMessage> Sends { get; } = [];
|
|
public List<FakeTelegramMessage> Edits { get; } = [];
|
|
public List<FakeDirectMessage> 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;
|
|
}
|
|
}
|