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>
362 lines
12 KiB
C#
362 lines
12 KiB
C#
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));
|
|
}
|