Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1853a7a9c7 | |||
| befb2da6a0 | |||
| d29c6c0725 | |||
| 47b22c7401 | |||
| b4a39c027f | |||
| dd9eab2e4a |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 2.7.1
|
||||
VERSION: 2.7.2
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>2.7.1</Version>
|
||||
<Version>2.7.2</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
||||
crond -f
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.7.1
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.7.2
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.7.1
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.7.2
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -84,7 +84,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:2.7.1
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:2.7.2
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v2.7.1</div>
|
||||
<div class="nav-version">v2.7.2</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -61,7 +61,7 @@ public sealed class DiscordProjectStructureTests
|
||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
||||
|
||||
Assert.Contains("gmrelay-discord-bot:2.7.1", 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("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
||||
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
||||
@@ -75,13 +75,13 @@ public sealed class DiscordProjectStructureTests
|
||||
{
|
||||
var repoRoot = GetRepoRoot();
|
||||
|
||||
Assert.Contains("<Version>2.7.1</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 2.7.1", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:2.7.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-web:2.7.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("gmrelay-discord-bot:2.7.1", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains("<Version>2.7.2</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||
Assert.Contains("VERSION: 2.7.2", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||
Assert.Contains("gmrelay-bot:2.7.2", 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.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||
Assert.Contains(
|
||||
"v2.7.1",
|
||||
"v2.7.2",
|
||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -136,4 +136,68 @@ public sealed class DiscordSessionBatchRendererTests
|
||||
Assert.Single(embeds);
|
||||
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.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);
|
||||
}
|
||||
|
||||
[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("<script>alert(1)</script>", 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&b=2", text);
|
||||
Assert.DoesNotContain("a=1&b=2" + "\"", text); // make sure & is encoded
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user