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); /// /// 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. /// public sealed class DatabaseAssertions(RunnerConfig config) { private NpgsqlConnection CreateConnection() => new(config.DatabaseUrl); public async Task 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 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> 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(); 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 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> 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(); 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; } /// /// 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. /// 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(); } /// /// Resets notification flags so a session can trigger the T-24h confirmation /// and T-5m join-link reminders again after time-travel. /// 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(); } /// /// 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. /// public async Task 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)); }