using System.Globalization; using TL; namespace GmRelay.E2E.Runner; /// /// 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 /// sessions.scheduled_at from the runner (a database-level time-mock). /// 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 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 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 FindParticipantAsync(Guid sessionId, string externalUserId, CancellationToken ct) { var participants = await _db.GetParticipantsAsync(sessionId); return participants.FirstOrDefault(p => p.ExternalUserId == externalUserId); } private async Task 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 WaitForNullableIntAsync( Func> 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."); } }