test: cover core bot and web scenarios
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user