test: cover core bot and web scenarios
Deploy Telegram Bot / build-and-push (push) Successful in 4m18s
Deploy Telegram Bot / deploy (push) Successful in 20s

This commit is contained in:
2026-04-23 21:08:41 +03:00
parent 93e7c1ac66
commit bb8cbb7a40
17 changed files with 716 additions and 234 deletions
@@ -1,14 +1,11 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
// ── Command ──────────────────────────────────────────────────────────
public sealed record HandleRsvpCommand(
Guid SessionId,
long TelegramUserId,
@@ -17,13 +14,12 @@ public sealed record HandleRsvpCommand(
long ChatId,
int MessageId);
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
internal sealed record SessionContext(
string Title,
DateTime ScheduledAt,
string Status,
long GmTelegramId,
long TelegramChatId);
@@ -33,21 +29,6 @@ internal sealed record ParticipantRsvp(
string? TelegramUsername,
string RsvpStatus);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles the "Буду" / "Не смогу" callback query.
///
/// Flow:
/// 1. Validate that the user is a participant in this session
/// 2. Record or update their RSVP (idempotent)
/// 3. If declined → alert GM privately, revert session if was Confirmed
/// 4. If all non-GM players confirmed → mark session Confirmed, notify group + GM
/// 5. Update the inline keyboard to show current RSVP status
///
/// Concurrency: two simultaneous clicks on different rows don't conflict (MVCC).
/// The last EditMessage wins, which is fine — both reflect up-to-date state.
/// </summary>
public sealed class HandleRsvpHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
@@ -58,12 +39,11 @@ public sealed class HandleRsvpHandler(
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// ── 1. Validate participant ──────────────────────────────────
var participantExists = await connection.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1 FROM session_participants sp
SELECT 1
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
@@ -82,8 +62,6 @@ public sealed class HandleRsvpHandler(
return;
}
// ── 2. Record RSVP (idempotent) ─────────────────────────────
var updated = await connection.ExecuteAsync(
"""
UPDATE session_participants
@@ -98,7 +76,6 @@ public sealed class HandleRsvpHandler(
if (updated == 0)
{
// Already in this state — just dismiss the loading spinner
var alreadyText = command.Status == RsvpStatus.Confirmed
? "Вы уже подтвердили участие."
: "Вы уже отказались от участия.";
@@ -110,11 +87,11 @@ public sealed class HandleRsvpHandler(
return;
}
// ── 3. Load session context ─────────────────────────────────
var session = await connection.QuerySingleAsync<SessionContext>(
"""
SELECT s.title, s.scheduled_at AS ScheduledAt,
SELECT s.title,
s.scheduled_at AS ScheduledAt,
s.status AS Status,
g.gm_telegram_id AS GmTelegramId,
g.telegram_chat_id AS TelegramChatId
FROM sessions s
@@ -124,26 +101,27 @@ public sealed class HandleRsvpHandler(
new { command.SessionId },
transaction);
// ── 4. Handle decline ───────────────────────────────────────
if (command.Status == RsvpStatus.Declined)
{
// Revert session to ConfirmationSent if it was Confirmed
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @ConfirmationSent, updated_at = now()
WHERE id = @SessionId AND status = @Confirmed
""",
new
{
command.SessionId,
ConfirmationSent = SessionStatus.ConfirmationSent,
Confirmed = SessionStatus.Confirmed
},
transaction);
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0);
if (decision.ShouldRevertSessionToConfirmationSent)
{
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @ConfirmationSent, updated_at = now()
WHERE id = @SessionId AND status = @Confirmed
""",
new
{
command.SessionId,
ConfirmationSent = SessionStatus.ConfirmationSent,
Confirmed = SessionStatus.Confirmed
},
transaction);
}
// Alert GM immediately via private message
var declinedPlayer = await connection.QuerySingleAsync<string>(
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
new { command.TelegramUserId },
@@ -151,7 +129,6 @@ public sealed class HandleRsvpHandler(
await transaction.CommitAsync(ct);
// Send alert outside transaction (network call)
try
{
await bot.SendMessage(
@@ -161,24 +138,22 @@ public sealed class HandleRsvpHandler(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}",
command.SessionId);
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", command.SessionId);
}
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: "Вы отказались от участия.",
text: decision.CallbackText,
cancellationToken: ct);
}
// ── 5. Handle confirm — check if ALL confirmed ──────────────
else
{
var counts = await connection.QuerySingleAsync<RsvpCounts>(
"""
SELECT
count(*) AS Total,
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
count(*) AS Total,
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
FROM session_participants
WHERE session_id = @SessionId AND is_gm = false
""",
@@ -190,9 +165,9 @@ public sealed class HandleRsvpHandler(
},
transaction);
var allConfirmed = counts.Confirmed == counts.Total;
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed);
if (allConfirmed)
if (decision.ShouldMarkSessionConfirmed)
{
await connection.ExecuteAsync(
"""
@@ -206,9 +181,8 @@ public sealed class HandleRsvpHandler(
await transaction.CommitAsync(ct);
if (allConfirmed)
if (decision.ShouldNotifyGroup)
{
// Notify group
try
{
await bot.SendMessage(
@@ -218,11 +192,12 @@ public sealed class HandleRsvpHandler(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}",
command.SessionId);
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", command.SessionId);
}
}
// Notify GM privately
if (decision.ShouldNotifyGm)
{
try
{
await bot.SendMessage(
@@ -232,27 +207,20 @@ public sealed class HandleRsvpHandler(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}",
command.SessionId);
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", command.SessionId);
}
}
await bot.AnswerCallbackQuery(
callbackQueryId: command.CallbackQueryId,
text: "Вы подтвердили участие!",
text: decision.CallbackText,
cancellationToken: ct);
}
// ── 6. Update inline keyboard message ───────────────────────
await UpdateConfirmationMessage(command, session, ct);
}
/// <summary>
/// Re-renders the confirmation message with current RSVP statuses.
/// </summary>
private async Task UpdateConfirmationMessage(
HandleRsvpCommand command, SessionContext session, CancellationToken ct)
private async Task UpdateConfirmationMessage(HandleRsvpCommand command, SessionContext session, CancellationToken ct)
{
try
{
@@ -260,10 +228,10 @@ public sealed class HandleRsvpHandler(
var participants = (await connection.QueryAsync<ParticipantRsvp>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.rsvp_status AS RsvpStatus
sp.rsvp_status AS RsvpStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false
@@ -279,34 +247,47 @@ public sealed class HandleRsvpHandler(
{
$"🎲 Подтвердите участие в «{session.Title}»",
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
""
string.Empty
};
foreach (var p in confirmed)
lines.Add($" ✅ {FormatName(p)}");
foreach (var p in declined)
lines.Add($" ❌ ~~{FormatName(p)}~~");
foreach (var p in pending)
lines.Add($" ⏳ {FormatName(p)}");
foreach (var participant in confirmed)
{
lines.Add($" ✅ {FormatName(participant)}");
}
lines.Add("");
foreach (var participant in declined)
{
lines.Add($" ❌ ~~{FormatName(participant)}~~");
}
foreach (var participant in pending)
{
lines.Add($" ⏳ {FormatName(participant)}");
}
lines.Add(string.Empty);
if (confirmed.Count == participants.Count)
{
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
}
else if (declined.Count > 0)
{
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
}
else
{
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
}
var text = string.Join("\n", lines);
// Keep buttons unless everyone confirmed
var replyMarkup = confirmed.Count == participants.Count
? null
: new InlineKeyboardMarkup([
[
InlineKeyboardButton.WithCallbackData("\u2705 Буду", $"rsvp:confirm:{command.SessionId}"),
InlineKeyboardButton.WithCallbackData("\u274c Не смогу", $"rsvp:decline:{command.SessionId}")
InlineKeyboardButton.WithCallbackData(" Буду", $"rsvp:confirm:{command.SessionId}"),
InlineKeyboardButton.WithCallbackData(" Не смогу", $"rsvp:decline:{command.SessionId}")
]
]);
@@ -319,12 +300,10 @@ public sealed class HandleRsvpHandler(
}
catch (Exception ex)
{
// EditMessage can fail if message is too old or unchanged — non-critical
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}",
command.SessionId);
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId);
}
}
private static string FormatName(ParticipantRsvp p) =>
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
private static string FormatName(ParticipantRsvp participant) =>
participant.TelegramUsername is not null ? $"@{participant.TelegramUsername}" : participant.DisplayName;
}