feat(web): dashboard session deletion and E2E coverage for issue #150
- 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>
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.E2E.Runner;
|
||||
|
||||
public sealed record SessionDto(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string Status,
|
||||
DateTimeOffset ScheduledAt,
|
||||
int? MaxPlayers,
|
||||
int? BatchMessageId,
|
||||
int? ConfirmationMessageId,
|
||||
int? LinkMessageId,
|
||||
string? JoinLink,
|
||||
Guid GroupId);
|
||||
|
||||
public sealed record ParticipantDto(
|
||||
Guid ParticipantId,
|
||||
string DisplayName,
|
||||
string ExternalUserId,
|
||||
string RegistrationStatus,
|
||||
string RsvpStatus,
|
||||
bool IsGm);
|
||||
|
||||
public sealed record RescheduleProposalDto(
|
||||
Guid Id,
|
||||
string Status,
|
||||
DateTimeOffset? VotingDeadlineAt,
|
||||
int? VoteMessageId,
|
||||
Guid? SelectedOptionId);
|
||||
|
||||
public sealed record RescheduleOptionDto(
|
||||
Guid Id,
|
||||
int DisplayOrder,
|
||||
DateTimeOffset ProposedAt);
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL helper for the E2E runner. Allows assertions against the actual
|
||||
/// application state and small, controlled seeds (e.g. a fake second player)
|
||||
/// that would otherwise require multiple real Telegram test accounts.
|
||||
/// </summary>
|
||||
public sealed class DatabaseAssertions(RunnerConfig config)
|
||||
{
|
||||
private NpgsqlConnection CreateConnection() => new(config.DatabaseUrl);
|
||||
|
||||
|
||||
public async Task<SessionDto?> GetSessionByIdAsync(Guid sessionId)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT s.id AS Id,
|
||||
s.title AS Title,
|
||||
s.status AS Status,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.max_players AS MaxPlayers,
|
||||
s.batch_message_id AS BatchMessageId,
|
||||
s.confirmation_message_id AS ConfirmationMessageId,
|
||||
s.link_message_id AS LinkMessageId,
|
||||
s.join_link AS JoinLink,
|
||||
s.group_id AS GroupId
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("@SessionId", sessionId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
if (!await reader.ReadAsync())
|
||||
return null;
|
||||
|
||||
return MapSession(reader);
|
||||
}
|
||||
|
||||
public async Task<SessionDto?> GetSessionByTitleAsync(long telegramChatId, string title)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT s.id AS Id,
|
||||
s.title AS Title,
|
||||
s.status AS Status,
|
||||
s.scheduled_at AS ScheduledAt,
|
||||
s.max_players AS MaxPlayers,
|
||||
s.batch_message_id AS BatchMessageId,
|
||||
s.confirmation_message_id AS ConfirmationMessageId,
|
||||
s.link_message_id AS LinkMessageId,
|
||||
s.join_link AS JoinLink,
|
||||
s.group_id AS GroupId
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE g.external_group_id::BIGINT = @ExternalGroupId
|
||||
AND s.title = @Title
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("@ExternalGroupId", telegramChatId);
|
||||
command.Parameters.AddWithValue("@Title", title);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
if (!await reader.ReadAsync())
|
||||
return null;
|
||||
|
||||
return MapSession(reader);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ParticipantDto>> GetParticipantsAsync(Guid sessionId)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT sp.id AS ParticipantId,
|
||||
p.display_name AS DisplayName,
|
||||
p.external_user_id AS ExternalUserId,
|
||||
sp.registration_status AS RegistrationStatus,
|
||||
sp.rsvp_status AS RsvpStatus,
|
||||
sp.is_gm AS IsGm
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
ORDER BY sp.created_at, sp.id
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("@SessionId", sessionId);
|
||||
|
||||
var participants = new List<ParticipantDto>();
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
participants.Add(new ParticipantDto(
|
||||
reader.GetGuid(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetString(3),
|
||||
reader.GetString(4),
|
||||
reader.GetBoolean(5)));
|
||||
}
|
||||
|
||||
return participants;
|
||||
}
|
||||
|
||||
public async Task<RescheduleProposalDto?> GetActiveRescheduleProposalAsync(Guid sessionId)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT id,
|
||||
status,
|
||||
voting_deadline_at,
|
||||
vote_message_id,
|
||||
selected_option_id
|
||||
FROM reschedule_proposals
|
||||
WHERE session_id = @SessionId
|
||||
AND status IN ('AwaitingTime', 'Voting')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("@SessionId", sessionId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
if (!await reader.ReadAsync())
|
||||
return null;
|
||||
|
||||
return new RescheduleProposalDto(
|
||||
reader.GetGuid(0),
|
||||
reader.GetString(1),
|
||||
reader.IsDBNull(2) ? null : new DateTimeOffset(reader.GetDateTime(2), TimeSpan.Zero),
|
||||
reader.IsDBNull(3) ? null : reader.GetInt32(3),
|
||||
reader.IsDBNull(4) ? null : reader.GetGuid(4));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RescheduleOptionDto>> GetRescheduleOptionsAsync(Guid proposalId)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT id, display_order, proposed_at
|
||||
FROM reschedule_options
|
||||
WHERE proposal_id = @ProposalId
|
||||
ORDER BY display_order
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("@ProposalId", proposalId);
|
||||
|
||||
var options = new List<RescheduleOptionDto>();
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
options.Add(new RescheduleOptionDto(
|
||||
reader.GetGuid(0),
|
||||
reader.GetInt32(1),
|
||||
reader.GetDateTime(2)));
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves a session forward or backward in time from the database perspective.
|
||||
/// Used as a time-mock so the scheduler background services fire quickly
|
||||
/// during local E2E runs instead of waiting real 24 hours.
|
||||
/// </summary>
|
||||
public async Task TimeTravelAsync(Guid sessionId, DateTimeOffset newScheduledAt)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET scheduled_at = @ScheduledAt,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("@ScheduledAt", newScheduledAt);
|
||||
command.Parameters.AddWithValue("@SessionId", sessionId);
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets notification flags so a session can trigger the T-24h confirmation
|
||||
/// and T-5m join-link reminders again after time-travel.
|
||||
/// </summary>
|
||||
public async Task ResetNotificationFlagsAsync(Guid sessionId)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET confirmation_sent_at = NULL,
|
||||
link_message_id = NULL,
|
||||
one_hour_reminder_processed_at = NULL,
|
||||
status = CASE WHEN status = 'ConfirmationSent' THEN 'Planned' ELSE status END,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("@SessionId", sessionId);
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a fake Telegram player and adds them to a session. This lets a
|
||||
/// single real test account exercise waitlist/promotion flows that would
|
||||
/// normally require a second Telegram user.
|
||||
/// </summary>
|
||||
public async Task<Guid> SeedFakeParticipantAsync(
|
||||
Guid sessionId,
|
||||
string displayName,
|
||||
long externalUserId,
|
||||
string registrationStatus,
|
||||
string rsvpStatus = "Pending",
|
||||
bool isGm = false)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
await using var transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
Guid playerId;
|
||||
|
||||
await using var playerCommand = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO players (display_name, platform, external_user_id, external_username)
|
||||
VALUES (@DisplayName, 'Telegram', @ExternalUserId, NULL)
|
||||
ON CONFLICT (platform, external_user_id)
|
||||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
||||
DO UPDATE
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
external_username = EXCLUDED.external_username
|
||||
RETURNING id
|
||||
""",
|
||||
connection,
|
||||
transaction);
|
||||
|
||||
playerCommand.Parameters.AddWithValue("@DisplayName", displayName);
|
||||
playerCommand.Parameters.AddWithValue("@ExternalUserId", externalUserId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
var result = await playerCommand.ExecuteScalarAsync();
|
||||
playerId = (Guid)(result ?? throw new InvalidOperationException("Failed to seed fake player."));
|
||||
|
||||
await using var participantCommand = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status, registration_status)
|
||||
VALUES (@SessionId, @PlayerId, @IsGm, @RsvpStatus, @RegistrationStatus)
|
||||
ON CONFLICT (session_id, player_id) DO UPDATE
|
||||
SET rsvp_status = EXCLUDED.rsvp_status,
|
||||
registration_status = EXCLUDED.registration_status,
|
||||
is_gm = EXCLUDED.is_gm
|
||||
RETURNING id
|
||||
""",
|
||||
connection,
|
||||
transaction);
|
||||
|
||||
participantCommand.Parameters.AddWithValue("@SessionId", sessionId);
|
||||
participantCommand.Parameters.AddWithValue("@PlayerId", playerId);
|
||||
participantCommand.Parameters.AddWithValue("@IsGm", isGm);
|
||||
participantCommand.Parameters.AddWithValue("@RsvpStatus", rsvpStatus);
|
||||
participantCommand.Parameters.AddWithValue("@RegistrationStatus", registrationStatus);
|
||||
|
||||
await participantCommand.ExecuteNonQueryAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
return playerId;
|
||||
}
|
||||
|
||||
public async Task UpdateSessionMaxPlayersAsync(Guid sessionId, int? maxPlayers)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"UPDATE sessions SET max_players = @MaxPlayers, updated_at = now() WHERE id = @SessionId",
|
||||
connection);
|
||||
|
||||
if (maxPlayers.HasValue)
|
||||
command.Parameters.AddWithValue("@MaxPlayers", maxPlayers.Value);
|
||||
else
|
||||
command.Parameters.AddWithValue("@MaxPlayers", DBNull.Value);
|
||||
|
||||
command.Parameters.AddWithValue("@SessionId", sessionId);
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static SessionDto MapSession(NpgsqlDataReader reader) =>
|
||||
new(
|
||||
reader.GetGuid(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetDateTime(3),
|
||||
reader.IsDBNull(4) ? null : reader.GetInt32(4),
|
||||
reader.IsDBNull(5) ? null : reader.GetInt32(5),
|
||||
reader.IsDBNull(6) ? null : reader.GetInt32(6),
|
||||
reader.IsDBNull(7) ? null : reader.GetInt32(7),
|
||||
reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||
reader.GetGuid(9));
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||
<PackageReference Include="WTelegramClient" Version="4.3.5" />
|
||||
<PackageReference Include="dotenv.net" Version="3.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ var config = new RunnerConfig
|
||||
PhoneNumber = Environment.GetEnvironmentVariable("phone_number")!,
|
||||
BotUsername = Environment.GetEnvironmentVariable("TELEGRAM_BOT_USERNAME")!,
|
||||
BotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN")!,
|
||||
DatabaseUrl = Environment.GetEnvironmentVariable("GMRELAY_E2E_DATABASE_URL")!,
|
||||
};
|
||||
|
||||
using var client = new TelegramUserClient(config);
|
||||
@@ -30,7 +31,11 @@ try
|
||||
JoinLink: "https://example.com/join-e2e");
|
||||
|
||||
await newSession.RunAsync(result.Group, inputs);
|
||||
Console.WriteLine("Full scenario completed successfully.");
|
||||
Console.WriteLine("Wizard scenario completed successfully.");
|
||||
|
||||
var lifecycle = new JoinLeaveWaitlistRescheduleScenario(client, config);
|
||||
await lifecycle.RunAsync(result.Group);
|
||||
Console.WriteLine("Lifecycle scenario completed successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -7,4 +7,5 @@ public sealed class RunnerConfig
|
||||
public required string PhoneNumber { get; init; }
|
||||
public required string BotUsername { get; init; }
|
||||
public required string BotToken { get; init; }
|
||||
public required string DatabaseUrl { get; init; }
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ public sealed class TelegramUserClient : IDisposable
|
||||
_client = new Client(_ => Environment.GetEnvironmentVariable(_));
|
||||
}
|
||||
|
||||
public long CurrentUserId => _client.UserId;
|
||||
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var me = await _client.LoginUserIfNeeded();
|
||||
|
||||
Reference in New Issue
Block a user