40fc435bda
- Add DeleteSessionAsync to ISessionStore/SessionService (unpublish portfolio card, remove bot-created empty forum topic, update batch message). - Add DeleteSessionForCurrentUserAsync to AuthorizedSessionService with audit log. - Add delete button + confirmation dialog to GroupDetails.razor. - Extend dashboard Playwright tests with edit persistence and delete verification. - Update AuthorizedSessionServiceTests with delete authorization coverage. - Mark issue #150 as done in tests/e2e/README.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
371 lines
14 KiB
C#
371 lines
14 KiB
C#
using System.Globalization;
|
|
using TL;
|
|
|
|
namespace GmRelay.E2E.Runner;
|
|
|
|
/// <summary>
|
|
/// E2E scenario covering the product lifecycle of a session after it has been
|
|
/// published: join, waitlist, promotion, leave + auto-promotion, reschedule
|
|
/// voting, T-24h RSVP confirmation and T-5m join-link notifications.
|
|
///
|
|
/// A single real Telegram test account is enough because waitlist scenarios
|
|
/// that need a second player are satisfied by seeding a fake participant
|
|
/// directly into PostgreSQL. Notification deadlines are accelerated by updating
|
|
/// <c>sessions.scheduled_at</c> from the runner (a database-level time-mock).
|
|
/// </summary>
|
|
public sealed class JoinLeaveWaitlistRescheduleScenario
|
|
{
|
|
private readonly TelegramUserClient _client;
|
|
private readonly RunnerConfig _config;
|
|
private readonly DatabaseAssertions _db;
|
|
|
|
public JoinLeaveWaitlistRescheduleScenario(TelegramUserClient client, RunnerConfig config)
|
|
{
|
|
_client = client;
|
|
_config = config;
|
|
_db = new DatabaseAssertions(config);
|
|
}
|
|
|
|
public async Task RunAsync(ChatGroup group, CancellationToken cancellationToken = default)
|
|
{
|
|
var baseSession = await CreateSessionAsync(group, maxPlayers: 2, cancellationToken);
|
|
Console.WriteLine($"[scenario] base session {baseSession.Id} created");
|
|
|
|
await JoinActiveAsync(group, baseSession, cancellationToken);
|
|
await RescheduleAndNotifyAsync(group, baseSession, cancellationToken);
|
|
await ManualPromoteAsync(group, baseSession, cancellationToken);
|
|
await AutoPromoteLeaveAsync(group, baseSession, cancellationToken);
|
|
|
|
var waitlistSession = await CreateSessionAsync(group, maxPlayers: 1, cancellationToken);
|
|
Console.WriteLine($"[scenario] waitlist session {waitlistSession.Id} created");
|
|
await JoinWaitlistAsync(group, waitlistSession, cancellationToken);
|
|
|
|
Console.WriteLine("[scenario] join/leave/waitlist/reschedule/notification flow completed");
|
|
}
|
|
|
|
private async Task<SessionDto> CreateSessionAsync(
|
|
ChatGroup group,
|
|
int? maxPlayers,
|
|
CancellationToken ct)
|
|
{
|
|
var title = $"E2E JLW {DateTime.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid().ToString()[..4]}";
|
|
var scheduledAtMoscow = DateTime.UtcNow
|
|
.AddDays(7)
|
|
.AddHours(3)
|
|
.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture);
|
|
|
|
var inputs = new NewSessionInputs(
|
|
Title: title,
|
|
ScheduledAtMoscow: scheduledAtMoscow,
|
|
MaxPlayers: maxPlayers ?? 5,
|
|
JoinLink: "https://example.com/join-e2e");
|
|
|
|
var wizard = new NewSessionScenario(_client, _config);
|
|
await wizard.RunAsync(group, inputs, ct);
|
|
|
|
var session = await _db.GetSessionByTitleAsync(group.Id, title);
|
|
return session ?? throw new InvalidOperationException($"Session '{title}' was not found in the database.");
|
|
}
|
|
|
|
private async Task JoinActiveAsync(ChatGroup group, SessionDto session, CancellationToken ct)
|
|
{
|
|
Console.WriteLine("[scenario] joining base session as active player");
|
|
await _client.ClickInlineButtonAsync(
|
|
group,
|
|
$"join_session:{session.Id}",
|
|
session.BatchMessageId,
|
|
ct);
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(2), ct);
|
|
|
|
var me = await GetCurrentParticipantAsync(session.Id, ct);
|
|
if (me?.RegistrationStatus != "Active")
|
|
throw new InvalidOperationException("Expected current user to be an active participant after join.");
|
|
|
|
Console.WriteLine("[scenario] joined as active player");
|
|
}
|
|
|
|
private async Task RescheduleAndNotifyAsync(ChatGroup group, SessionDto session, CancellationToken ct)
|
|
{
|
|
Console.WriteLine("[scenario] initiating reschedule");
|
|
|
|
await _client.ClickInlineButtonAsync(
|
|
group,
|
|
$"reschedule_session:{session.Id}",
|
|
session.BatchMessageId,
|
|
ct);
|
|
|
|
var prompt = await _client.WaitForBotReplyAsync(
|
|
group,
|
|
containsText: "Укажите 2-3 варианта времени",
|
|
timeout: TimeSpan.FromSeconds(30),
|
|
cancellationToken: ct)
|
|
?? throw new InvalidOperationException("Reschedule prompt was not received.");
|
|
|
|
var nowMoscow = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3));
|
|
var option1 = nowMoscow.AddMinutes(10);
|
|
var option2 = nowMoscow.AddMinutes(20);
|
|
var deadline = nowMoscow.AddMinutes(5);
|
|
|
|
var rescheduleText = string.Join(
|
|
"\n",
|
|
option1.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture),
|
|
option2.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture),
|
|
$"Дедлайн: {deadline.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture)}");
|
|
|
|
await _client.SendMessageAsync(group, rescheduleText, ct);
|
|
|
|
var voteMessage = await _client.WaitForBotReplyAsync(
|
|
group,
|
|
containsText: "Голосование за перенос",
|
|
timeout: TimeSpan.FromSeconds(30),
|
|
cancellationToken: ct)
|
|
?? throw new InvalidOperationException("Reschedule voting message was not received.");
|
|
|
|
var proposal = await WaitForActiveRescheduleProposalAsync(session.Id, ct);
|
|
var options = await _db.GetRescheduleOptionsAsync(proposal.Id);
|
|
var firstOption = options.FirstOrDefault()
|
|
?? throw new InvalidOperationException("No reschedule options found in the database.");
|
|
|
|
await _client.ClickInlineButtonAsync(
|
|
group,
|
|
$"reschedule_vote:{firstOption.Id}",
|
|
voteMessage.id,
|
|
ct);
|
|
|
|
Console.WriteLine($"[scenario] voted for option {firstOption.Id}; waiting for deadline service");
|
|
|
|
var finalDeadline = DateTime.UtcNow.AddMinutes(8);
|
|
while (DateTime.UtcNow < finalDeadline)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var updated = await _db.GetSessionByTitleAsync(group.Id, session.Title);
|
|
if (updated?.ScheduledAt >= option1.AddMinutes(-1) && updated?.ScheduledAt <= option1.AddMinutes(1))
|
|
{
|
|
Console.WriteLine("[scenario] reschedule applied");
|
|
break;
|
|
}
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(5), ct);
|
|
}
|
|
|
|
var afterReschedule = await _db.GetSessionByTitleAsync(group.Id, session.Title)
|
|
?? throw new InvalidOperationException("Session disappeared after reschedule.");
|
|
|
|
if (afterReschedule.Status != "Planned")
|
|
throw new InvalidOperationException($"Expected session status 'Planned' after reschedule, got '{afterReschedule.Status}'.");
|
|
|
|
Console.WriteLine("[scenario] waiting for T-24h confirmation request");
|
|
var confirmationMessageId = await WaitForNullableIntAsync(
|
|
async () => (await _db.GetSessionByTitleAsync(group.Id, session.Title))?.ConfirmationMessageId,
|
|
timeout: TimeSpan.FromMinutes(2),
|
|
ct);
|
|
|
|
await _client.ClickInlineButtonAsync(
|
|
group,
|
|
$"rsvp:confirm:{session.Id}",
|
|
confirmationMessageId,
|
|
ct);
|
|
|
|
await WaitForSessionStatusAsync(session.Id, "Confirmed", TimeSpan.FromMinutes(1), ct);
|
|
Console.WriteLine("[scenario] RSVP confirmed");
|
|
|
|
Console.WriteLine("[scenario] waiting for T-5m join link");
|
|
await WaitForBotMessageAsync(
|
|
group,
|
|
containsText: "начинается через 5 минут",
|
|
timeout: TimeSpan.FromMinutes(2),
|
|
ct);
|
|
|
|
var afterJoinLink = await _db.GetSessionByTitleAsync(group.Id, session.Title)
|
|
?? throw new InvalidOperationException("Session disappeared after join link.");
|
|
if (!afterJoinLink.LinkMessageId.HasValue)
|
|
throw new InvalidOperationException("Expected link_message_id to be populated after T-5m notification.");
|
|
|
|
Console.WriteLine("[scenario] T-24h confirmation and T-5m join link verified");
|
|
}
|
|
|
|
private async Task ManualPromoteAsync(ChatGroup group, SessionDto session, CancellationToken ct)
|
|
{
|
|
Console.WriteLine("[scenario] manual waitlist promotion");
|
|
|
|
await _db.SeedFakeParticipantAsync(
|
|
session.Id,
|
|
"E2E Fake Waitlisted",
|
|
externalUserId: 9000000001,
|
|
registrationStatus: "Waitlisted",
|
|
rsvpStatus: "Pending",
|
|
isGm: false);
|
|
|
|
await _db.UpdateSessionMaxPlayersAsync(session.Id, 2);
|
|
|
|
await _client.SendCommandAsync(group, "listsessions", ct);
|
|
|
|
var listMessage = await _client.WaitForBotReplyAsync(
|
|
group,
|
|
containsText: "Ближайшие игры",
|
|
timeout: TimeSpan.FromSeconds(30),
|
|
cancellationToken: ct)
|
|
?? throw new InvalidOperationException("/listsessions reply was not received.");
|
|
|
|
await _client.ClickInlineButtonAsync(
|
|
group,
|
|
$"promote_waitlist:{session.Id}",
|
|
listMessage.id,
|
|
ct);
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(2), ct);
|
|
|
|
var fake = await FindParticipantAsync(session.Id, "9000000001", ct);
|
|
if (fake?.RegistrationStatus != "Active")
|
|
throw new InvalidOperationException("Expected fake waitlisted participant to be promoted to active.");
|
|
|
|
Console.WriteLine("[scenario] manual promotion verified");
|
|
}
|
|
|
|
private async Task AutoPromoteLeaveAsync(ChatGroup group, SessionDto session, CancellationToken ct)
|
|
{
|
|
Console.WriteLine("[scenario] leave + automatic waitlist promotion");
|
|
|
|
await _db.SeedFakeParticipantAsync(
|
|
session.Id,
|
|
"E2E Fake Promotion",
|
|
externalUserId: 9000000002,
|
|
registrationStatus: "Waitlisted",
|
|
rsvpStatus: "Pending",
|
|
isGm: false);
|
|
|
|
await _client.ClickInlineButtonAsync(
|
|
group,
|
|
$"leave_session:{session.Id}",
|
|
session.BatchMessageId,
|
|
ct);
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(2), ct);
|
|
|
|
var me = await GetCurrentParticipantAsync(session.Id, ct);
|
|
if (me is not null)
|
|
throw new InvalidOperationException("Expected current user to be removed from session after leave.");
|
|
|
|
var fake = await FindParticipantAsync(session.Id, "9000000002", ct);
|
|
if (fake?.RegistrationStatus != "Active")
|
|
throw new InvalidOperationException("Expected fake waitlisted participant to be auto-promoted after leave.");
|
|
|
|
Console.WriteLine("[scenario] auto-promotion after leave verified");
|
|
}
|
|
|
|
private async Task JoinWaitlistAsync(ChatGroup group, SessionDto session, CancellationToken ct)
|
|
{
|
|
Console.WriteLine("[scenario] join to waitlist when capacity is full");
|
|
|
|
await _db.SeedFakeParticipantAsync(
|
|
session.Id,
|
|
"E2E Fake Active",
|
|
externalUserId: 9000000003,
|
|
registrationStatus: "Active",
|
|
rsvpStatus: "Pending",
|
|
isGm: false);
|
|
|
|
await _db.UpdateSessionMaxPlayersAsync(session.Id, 1);
|
|
|
|
await _client.ClickInlineButtonAsync(
|
|
group,
|
|
$"join_session:{session.Id}",
|
|
session.BatchMessageId,
|
|
ct);
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(2), ct);
|
|
|
|
var me = await GetCurrentParticipantAsync(session.Id, ct);
|
|
if (me?.RegistrationStatus != "Waitlisted")
|
|
throw new InvalidOperationException("Expected current user to be waitlisted when capacity is full.");
|
|
|
|
Console.WriteLine("[scenario] waitlist join verified");
|
|
}
|
|
|
|
private async Task<ParticipantDto?> GetCurrentParticipantAsync(Guid sessionId, CancellationToken ct)
|
|
{
|
|
var participants = await _db.GetParticipantsAsync(sessionId);
|
|
return participants.FirstOrDefault(p =>
|
|
p.ExternalUserId == _client.CurrentUserId.ToString(CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
private async Task<ParticipantDto?> FindParticipantAsync(Guid sessionId, string externalUserId, CancellationToken ct)
|
|
{
|
|
var participants = await _db.GetParticipantsAsync(sessionId);
|
|
return participants.FirstOrDefault(p => p.ExternalUserId == externalUserId);
|
|
}
|
|
|
|
private async Task<RescheduleProposalDto> WaitForActiveRescheduleProposalAsync(Guid sessionId, CancellationToken ct)
|
|
{
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var proposal = await _db.GetActiveRescheduleProposalAsync(sessionId);
|
|
if (proposal is not null)
|
|
return proposal;
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(1), ct);
|
|
}
|
|
|
|
throw new TimeoutException("Reschedule proposal was not created in the database.");
|
|
}
|
|
|
|
private async Task WaitForSessionStatusAsync(
|
|
Guid sessionId,
|
|
string expectedStatus,
|
|
TimeSpan timeout,
|
|
CancellationToken ct)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var session = await _db.GetSessionByIdAsync(sessionId);
|
|
if (session?.Status == expectedStatus)
|
|
return;
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(2), ct);
|
|
}
|
|
|
|
throw new TimeoutException($"Session did not reach status '{expectedStatus}' in time.");
|
|
}
|
|
|
|
private async Task WaitForBotMessageAsync(
|
|
ChatGroup group,
|
|
string containsText,
|
|
TimeSpan timeout,
|
|
CancellationToken ct)
|
|
{
|
|
var message = await _client.WaitForBotReplyAsync(
|
|
group,
|
|
containsText: containsText,
|
|
timeout: timeout,
|
|
cancellationToken: ct);
|
|
|
|
if (message is null)
|
|
throw new TimeoutException($"Bot message containing '{containsText}' was not received.");
|
|
}
|
|
|
|
private async Task<int> WaitForNullableIntAsync(
|
|
Func<Task<int?>> poll,
|
|
TimeSpan timeout,
|
|
CancellationToken ct)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var value = await poll();
|
|
if (value.HasValue)
|
|
return value.Value;
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(2), ct);
|
|
}
|
|
|
|
throw new TimeoutException("Expected nullable int value was not populated in time.");
|
|
}
|
|
}
|