Compare commits

..

14 Commits

Author SHA1 Message Date
Toutsu 9f7b772680 Merge pull request #90: test: добавить регрессионные тесты platform rendering и Discord MVP interactions (issue #33)
Deploy Telegram Bot / build-and-push (push) Successful in 4m43s
Deploy Telegram Bot / scan-images (push) Successful in 1m55s
Deploy Telegram Bot / deploy (push) Successful in 16s
2026-05-21 17:51:51 +03:00
Toutsu 1853a7a9c7 chore(release): bump version to 2.7.2
PR Checks / test-and-build (pull_request) Successful in 8m3s
Issue: #33
2026-05-21 15:36:17 +03:00
Toutsu befb2da6a0 test: add DiscordLandingPromisesSmokeTests
Mirror TelegramLandingPromisesSmokeTests for Discord MVP:
- Join/leave/waitlist promotion via capacity rules
- Reschedule voting flow
- Direct message notifications on reschedule
- Dashboard batch update

Issue: #33
2026-05-21 15:34:03 +03:00
Toutsu d29c6c0725 test: extend DiscordSessionBatchRendererTests with regression cases
- Confirmed status blue color
- Empty player description
- Embed URL from join link
- Inline field values for capacity/waitlist/status

Issue: #33
2026-05-21 15:21:02 +03:00
Toutsu 47b22c7401 test: extend TelegramSessionBatchRendererTests with regression cases
- Empty sessions
- HTML encoding in title
- Confirmed status buttons
- No join link handling

Issue: #33
2026-05-21 15:19:08 +03:00
Toutsu b4a39c027f test: extend SessionBatchViewBuilderTests with edge cases
- Empty sessions
- Confirmed status
- Null MaxPlayers

Issue: #33
2026-05-21 15:11:01 +03:00
Toutsu dd9eab2e4a Merge pull request #89: chore: добавить compose/deploy wiring для Discord bot (issue #32)
Deploy Telegram Bot / build-and-push (push) Successful in 4m50s
Deploy Telegram Bot / scan-images (push) Successful in 1m51s
Deploy Telegram Bot / deploy (push) Successful in 15s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:51:35 +03:00
Toutsu 492d47a863 fix(discord): add wget to Dockerfile for healthcheck
PR Checks / test-and-build (pull_request) Successful in 7m30s
Issue #32
2026-05-21 14:40:45 +03:00
Toutsu fe8d5fe026 test(discord): update version assertions to 2.7.1
PR Checks / test-and-build (pull_request) Successful in 7m7s
Issue #32
2026-05-21 14:26:02 +03:00
Toutsu a2fa9aaa6c chore(release): bump version to 2.7.1
Issue #32
2026-05-21 14:24:17 +03:00
Toutsu 5b65ac4a2f chore(discord): add healthcheck to compose.yaml discord service
Issue #32
2026-05-21 14:21:35 +03:00
Toutsu feb3e08b63 feat(discord): register health check hosted service in Program.cs
Issue #32
2026-05-21 14:20:40 +03:00
Toutsu f1d8f56fec feat(discord): add health check hosted service
Issue #32
2026-05-21 14:19:50 +03:00
Toutsu 08ffc6694e chore(discord): add DISCORD_BOT_TOKEN to .env.example
Issue #32
2026-05-21 14:19:02 +03:00
15 changed files with 801 additions and 13 deletions
+4
View File
@@ -10,6 +10,10 @@ TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE
# Используется ботом для кнопки меню Telegram и кнопки /start. # Используется ботом для кнопки меню Telegram и кнопки /start.
TELEGRAM_MINI_APP_URL= TELEGRAM_MINI_APP_URL=
# Токен Discord application bot
# Можно получить в Discord Developer Portal (https://discord.com/developers/applications)
DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE
# Пароль для базы данных PostgreSQL # Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=StrongPasswordForDatabase POSTGRES_PASSWORD=StrongPasswordForDatabase
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 2.7.0 VERSION: 2.7.2
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>2.7.0</Version> <Version>2.7.2</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+8 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.7.0 image: git.codeanddice.ru/toutsu/gmrelay-bot:2.7.2
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -67,7 +67,7 @@ services:
retries: 3 retries: 3
discord: discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.7.0 image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.7.2
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -77,9 +77,14 @@ services:
- "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}" - "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
networks: networks:
- gmrelay - gmrelay
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8082/health || exit 1"]
interval: 10s
timeout: 5s
retries: 3
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:2.7.0 image: git.codeanddice.ru/toutsu/gmrelay-web:2.7.2
restart: always restart: always
depends_on: depends_on:
db: db:
+5
View File
@@ -15,6 +15,11 @@ RUN dotnet publish "GmRelay.DiscordBot.csproj" -c Release -o /app/publish /p:Use
# Stage 2: Runtime # Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble AS final FROM mcr.microsoft.com/dotnet/runtime:10.0-noble AS final
WORKDIR /app WORKDIR /app
# Install wget for healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends wget \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish . COPY --from=build /app/publish .
USER $APP_UID USER $APP_UID
ENTRYPOINT ["dotnet", "GmRelay.DiscordBot.dll"] ENTRYPOINT ["dotnet", "GmRelay.DiscordBot.dll"]
@@ -0,0 +1,101 @@
using System.Net;
namespace GmRelay.DiscordBot.Infrastructure.Health;
public sealed class DiscordHealthCheckHostedService : IHostedService
{
private readonly ILogger<DiscordHealthCheckHostedService> _logger;
private readonly string _prefix;
private HttpListener? _listener;
private CancellationTokenSource? _cts;
private Task? _listenerTask;
public DiscordHealthCheckHostedService(
ILogger<DiscordHealthCheckHostedService> logger,
IConfiguration configuration)
{
_logger = logger;
_prefix = configuration.GetValue("HealthCheck:Prefix", "http://+:8082/")!;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_cts = new CancellationTokenSource();
_listener = new HttpListener();
_listener.Prefixes.Add(_prefix);
_listener.Start();
_logger.LogInformation("Discord health check server started on {Prefix}", _prefix);
_listenerTask = Task.Run(async () => await ListenAsync(_cts.Token), cancellationToken);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_cts?.Cancel();
_listener?.Stop();
if (_listenerTask != null)
{
await Task.WhenAny(_listenerTask, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken));
}
_listener?.Close();
_logger.LogInformation("Discord health check server stopped");
}
private async Task ListenAsync(CancellationToken cancellationToken)
{
while (_listener?.IsListening == true && !cancellationToken.IsCancellationRequested)
{
try
{
var context = await _listener.GetContextAsync();
_ = Task.Run(() => HandleRequestAsync(context), cancellationToken);
}
catch (HttpListenerException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in Discord health check listener");
}
}
}
private async Task HandleRequestAsync(HttpListenerContext context)
{
var response = context.Response;
try
{
var request = context.Request;
if (request.Url?.AbsolutePath == "/health")
{
response.StatusCode = (int)HttpStatusCode.OK;
response.ContentType = "application/json";
var body = "{\"status\":\"healthy\"}"u8.ToArray();
await response.OutputStream.WriteAsync(body);
}
else
{
response.StatusCode = (int)HttpStatusCode.NotFound;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling Discord health check request");
}
finally
{
response.Close();
}
}
}
+2
View File
@@ -2,6 +2,7 @@ using GmRelay.DiscordBot;
using GmRelay.DiscordBot.Features.Sessions; using GmRelay.DiscordBot.Features.Sessions;
using GmRelay.DiscordBot.Infrastructure; using GmRelay.DiscordBot.Infrastructure;
using GmRelay.DiscordBot.Infrastructure.Discord; using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Infrastructure.Health;
using GmRelay.DiscordBot.Infrastructure.Logging; using GmRelay.DiscordBot.Infrastructure.Logging;
using GmRelay.Shared.Features.Confirmation.HandleRsvp; using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Confirmation.SendConfirmation; using GmRelay.Shared.Features.Confirmation.SendConfirmation;
@@ -73,6 +74,7 @@ builder.Services.AddSingleton<HandleRsvpHandler>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>(); builder.Services.AddSingleton<RescheduleVotingFinalizer>();
builder.Services.AddHostedService<SessionSchedulerService>(); builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>(); builder.Services.AddHostedService<DiscordRescheduleVotingDeadlineService>();
builder.Services.AddHostedService<DiscordHealthCheckHostedService>();
builder.Services builder.Services
.AddDiscordGateway(options => .AddDiscordGateway(options =>
@@ -56,7 +56,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v2.7.0</div> <div class="nav-version">v2.7.2</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@@ -61,7 +61,7 @@ public sealed class DiscordProjectStructureTests
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml")); var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")); var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
Assert.Contains("gmrelay-discord-bot:2.7.0", compose); Assert.Contains("gmrelay-discord-bot:2.7.2", compose);
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose); Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy); Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
Assert.Contains("DISCORD_BOT_TOKEN", deploy); Assert.Contains("DISCORD_BOT_TOKEN", deploy);
@@ -75,13 +75,39 @@ public sealed class DiscordProjectStructureTests
{ {
var repoRoot = GetRepoRoot(); var repoRoot = GetRepoRoot();
Assert.Contains("<Version>2.7.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); Assert.Contains("<Version>2.7.2</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 2.7.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); Assert.Contains("VERSION: 2.7.2", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:2.7.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains("gmrelay-bot:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:2.7.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains("gmrelay-web:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:2.7.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains("gmrelay-discord-bot:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains( Assert.Contains(
"v2.7.0", "v2.7.2",
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
} }
[Fact]
public void EnvExample_ShouldContainDiscordBotToken()
{
var repoRoot = GetRepoRoot();
var envExample = File.ReadAllText(Path.Combine(repoRoot, ".env.example"));
Assert.Contains("DISCORD_BOT_TOKEN", envExample);
}
[Fact]
public void Compose_ShouldIncludeDiscordHealthcheck()
{
var repoRoot = GetRepoRoot();
var compose = File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"));
var discordIndex = compose.IndexOf("discord:", StringComparison.Ordinal);
Assert.True(discordIndex >= 0, "compose.yaml should contain discord service");
var nextServiceIndex = compose.IndexOf(" web:", StringComparison.Ordinal);
var discordBlock = compose[discordIndex..nextServiceIndex];
Assert.Contains("healthcheck:", discordBlock);
Assert.Contains("test:", discordBlock);
Assert.Contains("localhost:8082/health", discordBlock);
}
} }
@@ -87,6 +87,14 @@ public sealed class DiscordStartupTests
Assert.Contains("HandleRsvpHandler", program); Assert.Contains("HandleRsvpHandler", program);
} }
[Fact]
public void Program_ShouldRegisterDiscordHealthCheckHostedService()
{
var program = ReadProgram();
Assert.Contains("DiscordHealthCheckHostedService", program);
Assert.Contains("AddHostedService<DiscordHealthCheckHostedService>", program);
}
private static string ReadProgram() private static string ReadProgram()
{ {
var repoRoot = GetRepoRoot(); var repoRoot = GetRepoRoot();
@@ -0,0 +1,410 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Rendering;
using NetCord.Rest;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Tests.Features.Landing;
public sealed class DiscordLandingPromisesSmokeTests
{
[Fact]
public void Smoke_ShouldCoverDiscordLandingPromisesWithoutExternalDiscordApi()
{
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
Assert.True(parseResult.IsValid);
Assert.Equal(3, parseResult.ScheduledTimes.Count);
Assert.Equal(2, parseResult.MaxPlayers);
Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]);
var scenario = DiscordLandingSmokeScenario.Publish(parseResult, SessionNotificationMode.GroupAndDirect);
var publishedCallbacks = CallbackData(scenario.LastMessage.ActionRows);
Assert.Contains(scenario.LastMessage.Embeds, e => e.Title!.Contains("Landing Promise Smoke"));
Assert.Equal(6, publishedCallbacks.Count);
Assert.All(scenario.Sessions, session =>
{
Assert.Contains($"join_session:{session.Id}", publishedCallbacks);
Assert.Contains($"leave_session:{session.Id}", publishedCallbacks);
Assert.Contains(scenario.LastMessage.Embeds, embed => embed.Title!.Contains(session.ScheduledAt.FormatMoscow()));
});
var firstSessionId = scenario.Sessions[0].Id;
var alice = scenario.Join(firstSessionId, 1001UL, "Alice");
var bob = scenario.Join(firstSessionId, 1002UL, "Bob");
var carol = scenario.Join(firstSessionId, 1003UL, "Carol");
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, alice));
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, bob));
Assert.Equal(ParticipantRegistrationStatus.Waitlisted, scenario.RegistrationStatus(firstSessionId, carol));
var firstSessionEmbed = scenario.LastMessage.Embeds.First(e => e.Title!.Contains(scenario.Sessions[0].ScheduledAt.FormatMoscow()));
Assert.Equal("2/2", firstSessionEmbed.Fields!.First().Value);
Assert.Contains("Alice", firstSessionEmbed.Description);
Assert.Contains("Bob", firstSessionEmbed.Description);
Assert.Contains("Carol", firstSessionEmbed.Description);
scenario.Leave(firstSessionId, alice);
Assert.False(scenario.HasParticipant(firstSessionId, alice));
Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, carol));
firstSessionEmbed = scenario.LastMessage.Embeds.First(e => e.Title!.Contains(scenario.Sessions[0].ScheduledAt.FormatMoscow()));
Assert.DoesNotContain("Alice", firstSessionEmbed.Description);
Assert.Contains("Carol", firstSessionEmbed.Description);
scenario.MarkRsvpConfirmed(firstSessionId, bob);
scenario.MarkRsvpConfirmed(firstSessionId, carol);
var option1Id = Guid.NewGuid();
var option2Id = Guid.NewGuid();
var options = new[]
{
new RescheduleOptionDto(option1Id, 1, new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero)),
new RescheduleOptionDto(option2Id, 2, new DateTimeOffset(2026, 5, 30, 15, 0, 0, TimeSpan.Zero))
};
var deadline = new DateTimeOffset(2026, 5, 20, 18, 0, 0, TimeSpan.Zero);
var voteParticipants = scenario.ActiveVoteParticipants(firstSessionId);
var voteMessage = HandleRescheduleTimeInputHandler.BuildVotingMessage(
scenario.Title,
scenario.Sessions[0].ScheduledAt,
deadline,
options,
voteParticipants,
[]);
var voteKeyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
Assert.Contains("Landing Promise Smoke", voteMessage);
Assert.Contains("0/2", voteMessage);
Assert.Contains($"reschedule_vote:{option1Id}", CallbackData(voteKeyboard));
Assert.Contains($"reschedule_vote:{option2Id}", CallbackData(voteKeyboard));
var votes = voteParticipants
.Select(participant => new RescheduleOptionVoteDto(
option2Id,
participant.PlayerId,
participant.DisplayName,
participant.TelegramUsername))
.ToList();
var decision = RescheduleVoteRules.SelectWinner(
options.Select(option => new RescheduleOptionVoteCount(
option.OptionId,
votes.Count(vote => vote.OptionId == option.OptionId))).ToList());
Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome);
Assert.Equal(option2Id, decision.SelectedOptionId);
scenario.ApplyReschedule(firstSessionId, options[1].ProposedAt);
Assert.Equal(options[1].ProposedAt.UtcDateTime, scenario.Sessions[0].ScheduledAt);
Assert.Equal(GmRelay.Shared.Domain.RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, bob));
Assert.Equal(GmRelay.Shared.Domain.RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, carol));
Assert.Contains(scenario.LastMessage.Embeds, e => e.Title!.Contains(options[1].ProposedAt.UtcDateTime.FormatMoscow()));
Assert.Collection(
scenario.Messenger.DirectMessages.Select(message => message.DiscordId).Order(),
discordId => Assert.Equal(1002UL, discordId),
discordId => Assert.Equal(1003UL, discordId));
Assert.All(scenario.Messenger.DirectMessages, message =>
{
Assert.Contains("Landing Promise Smoke", message.Text);
Assert.Contains(options[1].ProposedAt.UtcDateTime.FormatMoscow(), message.Text);
});
var editsBeforeDashboardUpdate = scenario.Messenger.Edits.Count;
scenario.UpdateBatchFromDashboard("Landing Promise Smoke - Dashboard Sync");
Assert.True(scenario.Messenger.Edits.Count > editsBeforeDashboardUpdate);
Assert.Contains(scenario.LastMessage.Embeds, e => e.Title!.Contains("Landing Promise Smoke - Dashboard Sync"));
firstSessionEmbed = scenario.LastMessage.Embeds.First(e => e.Title!.Contains(scenario.Sessions[0].ScheduledAt.FormatMoscow()));
Assert.Contains("Bob", firstSessionEmbed.Description);
Assert.Contains("Carol", firstSessionEmbed.Description);
}
private static string BuildRecurringSessionCommand() =>
string.Join(
'\n',
"/newsession",
"Название: Landing Promise Smoke",
"Время: 15.05.2026 19:30",
"Игр: 3",
"Интервал: 7",
"Мест: 2",
"Ссылка: https://example.test/table");
private static IReadOnlyList<string> CallbackData(IReadOnlyList<ActionRowProperties> actionRows) =>
actionRows
.SelectMany(row => row)
.OfType<ButtonProperties>()
.Select(button => button.CustomId)
.OfType<string>()
.ToList();
private static IReadOnlyList<string> CallbackData(InlineKeyboardMarkup markup) =>
markup.InlineKeyboard
.SelectMany(row => row)
.Select(button => button.CallbackData)
.OfType<string>()
.ToList();
private sealed class DiscordLandingSmokeScenario
{
private readonly List<SmokeSession> sessions;
private readonly List<SmokeParticipant> participants = [];
private readonly SessionNotificationMode notificationMode;
private DiscordLandingSmokeScenario(
string title,
IReadOnlyList<DateTimeOffset> scheduledTimes,
int? maxPlayers,
string joinLink,
SessionNotificationMode notificationMode)
{
Title = title;
this.notificationMode = notificationMode;
sessions = scheduledTimes
.Select(scheduledAt => new SmokeSession(
Guid.NewGuid(),
scheduledAt.UtcDateTime,
SessionStatus.Planned,
maxPlayers,
joinLink))
.ToList();
}
public string Title { get; private set; }
public FakeDiscordMessenger Messenger { get; } = new();
public IReadOnlyList<SmokeSession> Sessions => sessions;
public FakeDiscordMessage LastMessage => Messenger.LastMessage;
public static DiscordLandingSmokeScenario Publish(
NewSessionParseResult parseResult,
SessionNotificationMode notificationMode)
{
var scenario = new DiscordLandingSmokeScenario(
parseResult.Title!,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
parseResult.Link!,
notificationMode);
scenario.RenderBatch();
return scenario;
}
public Guid Join(Guid sessionId, ulong discordId, string displayName)
{
var session = sessions.Single(value => value.Id == sessionId);
var activeParticipants = participants.Count(participant =>
participant.SessionId == sessionId &&
participant.RegistrationStatus == ParticipantRegistrationStatus.Active);
var registrationStatus = SessionCapacityRules.DecideJoinStatus(
session.MaxPlayers,
activeParticipants);
var playerId = Guid.NewGuid();
participants.Add(new SmokeParticipant(
sessionId,
playerId,
discordId,
displayName,
registrationStatus,
GmRelay.Shared.Domain.RsvpStatus.Pending,
participants.Count));
RenderBatch();
return playerId;
}
public void Leave(Guid sessionId, Guid playerId)
{
var participant = participants.Single(value =>
value.SessionId == sessionId &&
value.PlayerId == playerId);
participants.Remove(participant);
var session = sessions.Single(value => value.Id == sessionId);
var activeParticipantsAfterLeave = participants.Count(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Active);
var waitlistedParticipants = participants.Count(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted);
if (SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
participant.RegistrationStatus,
session.MaxPlayers,
activeParticipantsAfterLeave,
waitlistedParticipants))
{
var promoted = participants
.Where(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted)
.OrderBy(value => value.JoinOrder)
.First();
promoted.RegistrationStatus = ParticipantRegistrationStatus.Active;
promoted.RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Pending;
}
RenderBatch();
}
public bool HasParticipant(Guid sessionId, Guid playerId) =>
participants.Any(value => value.SessionId == sessionId && value.PlayerId == playerId);
public string RegistrationStatus(Guid sessionId, Guid playerId) =>
participants.Single(value =>
value.SessionId == sessionId &&
value.PlayerId == playerId).RegistrationStatus;
public string RsvpStatus(Guid sessionId, Guid playerId) =>
participants.Single(value =>
value.SessionId == sessionId &&
value.PlayerId == playerId).RsvpStatus;
public void MarkRsvpConfirmed(Guid sessionId, Guid playerId)
{
participants.Single(value =>
value.SessionId == sessionId &&
value.PlayerId == playerId).RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Confirmed;
}
public IReadOnlyList<VoteParticipantDto> ActiveVoteParticipants(Guid sessionId) =>
participants
.Where(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Active)
.OrderBy(value => value.DisplayName)
.Select(value => new VoteParticipantDto(
value.PlayerId,
value.DisplayName,
null,
0))
.ToList();
public void ApplyReschedule(Guid sessionId, DateTimeOffset newScheduledAt)
{
sessions.Single(value => value.Id == sessionId).ScheduledAt = newScheduledAt.UtcDateTime;
foreach (var participant in participants.Where(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Active))
{
participant.RsvpStatus = GmRelay.Shared.Domain.RsvpStatus.Pending;
}
RenderBatch();
if (!notificationMode.ShouldSendDirectMessages())
{
return;
}
var notification = $"""
Reschedule approved
{Title}
{newScheduledAt.UtcDateTime.FormatMoscow()}
""";
foreach (var participant in participants.Where(value =>
value.SessionId == sessionId &&
value.RegistrationStatus == ParticipantRegistrationStatus.Active))
{
Messenger.SendDirectMessage(participant.DiscordId, notification);
}
}
public void UpdateBatchFromDashboard(string title)
{
Title = title;
RenderBatch();
}
private void RenderBatch()
{
var view = SessionBatchViewBuilder.Build(
Title,
sessions
.Select(session => new SessionBatchDto(
session.Id,
session.ScheduledAt,
session.Status,
session.MaxPlayers,
session.JoinLink))
.ToList(),
participants
.Select(participant => new ParticipantBatchDto(
participant.SessionId,
participant.DisplayName,
null,
participant.RegistrationStatus))
.ToList());
var renderResult = DiscordSessionBatchRenderer.Render(view);
if (Messenger.HasPublishedMessage)
{
Messenger.EditBatchMessage(renderResult.Embeds, renderResult.ActionRows);
}
else
{
Messenger.PublishBatchMessage(renderResult.Embeds, renderResult.ActionRows);
}
}
}
private sealed class FakeDiscordMessenger
{
private const int BatchMessageId = 7001;
public List<FakeDiscordMessage> Sends { get; } = [];
public List<FakeDiscordMessage> Edits { get; } = [];
public List<FakeDirectMessage> DirectMessages { get; } = [];
public bool HasPublishedMessage => Sends.Count > 0;
public FakeDiscordMessage LastMessage => Edits.Count > 0 ? Edits[^1] : Sends[^1];
public void PublishBatchMessage(IReadOnlyList<EmbedProperties> embeds, IReadOnlyList<ActionRowProperties> actionRows) =>
Sends.Add(new FakeDiscordMessage(BatchMessageId, embeds, actionRows));
public void EditBatchMessage(IReadOnlyList<EmbedProperties> embeds, IReadOnlyList<ActionRowProperties> actionRows) =>
Edits.Add(new FakeDiscordMessage(BatchMessageId, embeds, actionRows));
public void SendDirectMessage(ulong discordId, string text) =>
DirectMessages.Add(new FakeDirectMessage(discordId, text));
}
private sealed record FakeDiscordMessage(
int MessageId,
IReadOnlyList<EmbedProperties> Embeds,
IReadOnlyList<ActionRowProperties> ActionRows);
private sealed record FakeDirectMessage(ulong DiscordId, string Text);
private sealed record SmokeSession(
Guid Id,
DateTime ScheduledAt,
string Status,
int? MaxPlayers,
string JoinLink)
{
public DateTime ScheduledAt { get; set; } = ScheduledAt;
}
private sealed record SmokeParticipant(
Guid SessionId,
Guid PlayerId,
ulong DiscordId,
string DisplayName,
string RegistrationStatus,
string RsvpStatus,
int JoinOrder)
{
public string RegistrationStatus { get; set; } = RegistrationStatus;
public string RsvpStatus { get; set; } = RsvpStatus;
}
}
@@ -0,0 +1,53 @@
using System.Net;
using System.Net.Sockets;
using GmRelay.DiscordBot.Infrastructure.Health;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace GmRelay.Bot.Tests.Infrastructure.Health;
public sealed class DiscordHealthCheckHostedServiceTests : IDisposable
{
private readonly DiscordHealthCheckHostedService _service;
private readonly int _port;
public DiscordHealthCheckHostedServiceTests()
{
_port = GetAvailablePort();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["HealthCheck:Prefix"] = $"http://localhost:{_port}/"
})
.Build();
_service = new DiscordHealthCheckHostedService(
NullLogger<DiscordHealthCheckHostedService>.Instance,
config);
}
public void Dispose()
{
_service.StopAsync(CancellationToken.None).Wait(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task HealthEndpoint_ShouldReturn200_WhenServiceIsRunning()
{
await _service.StartAsync(CancellationToken.None);
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
var response = await client.GetAsync($"http://localhost:{_port}/health");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
private static int GetAvailablePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}
@@ -136,4 +136,68 @@ public sealed class DiscordSessionBatchRendererTests
Assert.Single(embeds); Assert.Single(embeds);
Assert.Single(actionRows); // not cancelled → actions present Assert.Single(actionRows); // not cancelled → actions present
} }
[Fact]
public void Render_ShouldUseBlueColorForConfirmedSessions()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Confirmed, 4, "https://example.com/game") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
Assert.Equal(0x5865F2, embeds[0].Color.RawValue);
}
[Fact]
public void Render_ShouldShowEmptyPlayerDescription()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/game") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
Assert.Contains("Пока никто не записался", embeds[0].Description);
}
[Fact]
public void Render_ShouldSetEmbedUrlWhenJoinLinkPresent()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/game") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
Assert.Equal("https://example.com/game", embeds[0].Url);
}
[Fact]
public void Render_ShouldEmbedCorrectFieldValues()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/game") };
var participants = new[]
{
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Waitlisted)
};
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (embeds, _) = DiscordSessionBatchRenderer.Render(view);
var embed = embeds[0];
var fields = embed.Fields!.ToList();
Assert.Equal(3, fields.Count);
Assert.Equal("👥 Заполненность", fields[0].Name);
Assert.Equal("1/4", fields[0].Value);
Assert.Equal("⏳ Лист ожидания", fields[1].Name);
Assert.Equal("1", fields[1].Value);
Assert.Equal("📊 Статус", fields[2].Name);
Assert.Equal("Запланирована", fields[2].Value);
}
} }
@@ -110,4 +110,43 @@ public sealed class SessionBatchViewBuilderTests
Assert.Equal("Alice", player.DisplayName); Assert.Equal("Alice", player.DisplayName);
Assert.Equal("alice", player.TelegramUsername); Assert.Equal("alice", player.TelegramUsername);
} }
[Fact]
public void Build_ShouldHandleEmptySessions()
{
var result = SessionBatchViewBuilder.Build("Empty", Array.Empty<SessionBatchDto>(), Array.Empty<ParticipantBatchDto>());
Assert.Equal("Empty", result.Title);
Assert.Empty(result.Sessions);
}
[Fact]
public void Build_ShouldHandleConfirmedStatus()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Confirmed, 4, "https://example.com/game") };
var participants = Array.Empty<ParticipantBatchDto>();
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
Assert.Equal(SessionStatus.Confirmed, result.Sessions[0].Status);
Assert.Equal(2, result.Sessions[0].AvailableActions.Count);
Assert.Equal("join_session", result.Sessions[0].AvailableActions[0].ActionKey);
Assert.Equal("leave_session", result.Sessions[0].AvailableActions[1].ActionKey);
Assert.Equal(sessionId, result.Sessions[0].AvailableActions[0].SessionId);
Assert.Equal(sessionId, result.Sessions[0].AvailableActions[1].SessionId);
}
[Fact]
public void Build_ShouldHandleNullMaxPlayers()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, null, "https://example.com/game") };
var participants = Array.Empty<ParticipantBatchDto>();
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
Assert.Null(result.Sessions[0].MaxPlayers);
var joinAction = result.Sessions[0].AvailableActions.First(a => a.ActionKey == "join_session");
Assert.DoesNotContain("ожидания", joinAction.Label);
}
} }
@@ -77,4 +77,75 @@ public sealed class TelegramSessionBatchRendererTests
Assert.Contains("ожидания", joinButton.Text); Assert.Contains("ожидания", joinButton.Text);
} }
[Fact]
public void Render_ShouldHandleEmptySessions()
{
var view = SessionBatchViewBuilder.Build("Empty", Array.Empty<SessionBatchDto>(), Array.Empty<ParticipantBatchDto>());
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
Assert.Contains("Empty", text);
Assert.DoesNotContain("📅", text);
Assert.Empty(markup.InlineKeyboard);
}
[Fact]
public void Render_ShouldEncodeHtmlInTitle()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("<script>alert(1)</script>", sessions, participants);
var (text, _) = TelegramSessionBatchRenderer.Render(view);
Assert.Contains("&lt;script&gt;alert(1)&lt;/script&gt;", text);
Assert.DoesNotContain("<script>", text);
}
[Fact]
public void Render_ShouldShowConfirmedStatusButtons()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Confirmed, 4, "") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (_, markup) = TelegramSessionBatchRenderer.Render(view);
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
Assert.Equal(2, buttons.Count);
Assert.Contains(buttons, b => b.CallbackData == $"join_session:{sessionId}");
Assert.Contains(buttons, b => b.CallbackData == $"leave_session:{sessionId}");
}
[Fact]
public void Render_ShouldHandleNoJoinLink()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
Assert.DoesNotContain("Ссылка на игру", text);
Assert.Contains("📅", text);
Assert.Equal(2, buttons.Count);
}
[Fact]
public void Render_ShouldEncodeHtmlInJoinLink()
{
var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/test?a=1&b=2") };
var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
var (text, _) = TelegramSessionBatchRenderer.Render(view);
Assert.Contains("a=1&amp;b=2", text);
Assert.DoesNotContain("a=1&b=2" + "\"", text); // make sure & is encoded
}
} }