From 5dee2d87f5d5172d7b8f506500564a653e3f96c7 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 5 May 2026 13:06:09 +0300 Subject: [PATCH] test: cover Telegram landing promise smoke --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- README.md | 14 +- compose.yaml | 4 +- .../Components/Layout/NavMenu.razor | 2 +- src/GmRelay.Web/wwwroot/app.css | 2 +- .../TelegramLandingPromisesSmokeTests.cs | 390 ++++++++++++++++++ .../Web/AuthorizedSessionServiceTests.cs | 16 + .../Web/CampaignTemplatesNavigationTests.cs | 16 + 9 files changed, 441 insertions(+), 7 deletions(-) create mode 100644 tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8c0f977..ed8116c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 1.9.8 + VERSION: 1.9.9 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 1a2464b..2eda9c7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.9.7 + 1.9.9 net10.0 preview enable diff --git a/README.md b/README.md index 0596a26..ad2834b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v1.9.8`. +**Текущая версия:** `v1.9.9`. --- @@ -212,5 +212,17 @@ Owner и co-GM могут открыть мобильный dashboard прямо --- +## 🧪 Тестирование + +Основной набор проверок запускается командой: + +```bash +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --collect:"XPlat Code Coverage" +``` + +Начиная с `v1.9.9`, тестовый набор включает функциональный smoke-сценарий обещаний лендинга для Telegram: batch-сессии на несколько дат, inline-кнопки записи/выхода, лимиты мест, waitlist, автоповышение игрока, голосование за перенос, direct-notification mode и перерисовку Telegram batch-поста после dashboard-изменений. Smoke работает через fake Telegram messenger и не требует внешнего Telegram API. + +--- + ## 📜 Лицензия Проект распространяется под лицензией MIT. Использование в некоммерческих целях приветствуется. diff --git a/compose.yaml b/compose.yaml index b04fc08..791836d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,7 @@ services: retries: 10 bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.7 + image: git.codeanddice.ru/toutsu/gmrelay-bot:1.9.9 restart: always depends_on: db: @@ -30,7 +30,7 @@ services: - gmrelay web: - image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.7 + image: git.codeanddice.ru/toutsu/gmrelay-web:1.9.9 restart: always depends_on: db: diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 0322828..ae2ef2d 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -56,7 +56,7 @@ - + diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index 2d43eae..d6a3261 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -1,5 +1,5 @@ /* ============================================ - GM-Relay Design System v1.9.6 + GM-Relay Design System v1.9.9 Dark RPG Dashboard Theme ============================================ */ diff --git a/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs b/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs new file mode 100644 index 0000000..87932b8 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Landing/TelegramLandingPromisesSmokeTests.cs @@ -0,0 +1,390 @@ +using GmRelay.Bot.Features.Sessions.CreateSession; +using GmRelay.Bot.Features.Sessions.RescheduleSession; +using GmRelay.Shared.Domain; +using GmRelay.Shared.Rendering; +using Telegram.Bot.Types.ReplyMarkups; + +namespace GmRelay.Bot.Tests.Features.Landing; + +public sealed class TelegramLandingPromisesSmokeTests +{ + [Fact] + public void Smoke_ShouldCoverTelegramLandingPromisesWithoutExternalTelegramApi() + { + 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 = TelegramLandingSmokeScenario.Publish(parseResult, SessionNotificationMode.GroupAndDirect); + var publishedCallbacks = CallbackData(scenario.LastMessage.Markup); + + Assert.Contains("Landing Promise Smoke", scenario.LastMessage.Text); + 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(session.ScheduledAt.FormatMoscow(), scenario.LastMessage.Text); + }); + + var firstSessionId = scenario.Sessions[0].Id; + var alice = scenario.Join(firstSessionId, 1001, "Alice", "alice"); + var bob = scenario.Join(firstSessionId, 1002, "Bob", "bob"); + var carol = scenario.Join(firstSessionId, 1003, "Carol", "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)); + Assert.Contains("2/2", scenario.LastMessage.Text); + Assert.Contains("@alice", scenario.LastMessage.Text); + Assert.Contains("@bob", scenario.LastMessage.Text); + Assert.Contains("@carol", scenario.LastMessage.Text); + + scenario.Leave(firstSessionId, alice); + + Assert.False(scenario.HasParticipant(firstSessionId, alice)); + Assert.Equal(ParticipantRegistrationStatus.Active, scenario.RegistrationStatus(firstSessionId, carol)); + Assert.DoesNotContain("@alice", scenario.LastMessage.Text); + Assert.Contains("@carol", scenario.LastMessage.Text); + + 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(RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, bob)); + Assert.Equal(RsvpStatus.Pending, scenario.RsvpStatus(firstSessionId, carol)); + Assert.Contains(options[1].ProposedAt.UtcDateTime.FormatMoscow(), scenario.LastMessage.Text); + Assert.Collection( + scenario.Messenger.DirectMessages.Select(message => message.TelegramId).Order(), + telegramId => Assert.Equal(1002, telegramId), + telegramId => Assert.Equal(1003, telegramId)); + 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("Landing Promise Smoke - Dashboard Sync", scenario.LastMessage.Text); + Assert.Contains("@bob", scenario.LastMessage.Text); + Assert.Contains("@carol", scenario.LastMessage.Text); + } + + private static string BuildRecurringSessionCommand() => + string.Join( + '\n', + "/newsession", + "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435: Landing Promise Smoke", + "\u0412\u0440\u0435\u043c\u044f: 15.05.2026 19:30", + "\u0418\u0433\u0440: 3", + "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b: 7", + "\u041c\u0435\u0441\u0442: 2", + "\u0421\u0441\u044b\u043b\u043a\u0430: https://example.test/table"); + + private static IReadOnlyList CallbackData(InlineKeyboardMarkup markup) => + markup.InlineKeyboard + .SelectMany(row => row) + .Select(button => button.CallbackData) + .OfType() + .ToList(); + + private sealed class TelegramLandingSmokeScenario + { + private readonly List sessions; + private readonly List participants = []; + private readonly SessionNotificationMode notificationMode; + + private TelegramLandingSmokeScenario( + string title, + IReadOnlyList scheduledTimes, + int? maxPlayers, + SessionNotificationMode notificationMode) + { + Title = title; + this.notificationMode = notificationMode; + sessions = scheduledTimes + .Select(scheduledAt => new SmokeSession( + Guid.NewGuid(), + scheduledAt.UtcDateTime, + SessionStatus.Planned, + maxPlayers)) + .ToList(); + } + + public string Title { get; private set; } + public FakeTelegramMessenger Messenger { get; } = new(); + public IReadOnlyList Sessions => sessions; + public FakeTelegramMessage LastMessage => Messenger.LastMessage; + + public static TelegramLandingSmokeScenario Publish( + NewSessionParseResult parseResult, + SessionNotificationMode notificationMode) + { + var scenario = new TelegramLandingSmokeScenario( + parseResult.Title!, + parseResult.ScheduledTimes, + parseResult.MaxPlayers, + notificationMode); + + scenario.RenderBatch(); + return scenario; + } + + public Guid Join(Guid sessionId, long telegramId, string displayName, string? telegramUsername) + { + 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, + telegramId, + displayName, + telegramUsername, + 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 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, + value.TelegramUsername, + value.TelegramId)) + .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.TelegramId, notification); + } + } + + public void UpdateBatchFromDashboard(string title) + { + Title = title; + RenderBatch(); + } + + private void RenderBatch() + { + var renderResult = SessionBatchRenderer.Render( + Title, + sessions + .Select(session => new SessionBatchDto( + session.Id, + session.ScheduledAt, + session.Status, + session.MaxPlayers)) + .ToList(), + participants + .Select(participant => new ParticipantBatchDto( + participant.SessionId, + participant.DisplayName, + participant.TelegramUsername, + participant.RegistrationStatus)) + .ToList()); + + if (Messenger.HasPublishedMessage) + { + Messenger.EditBatchMessage(renderResult.Text, renderResult.Markup); + } + else + { + Messenger.PublishBatchMessage(renderResult.Text, renderResult.Markup); + } + } + } + + private sealed class FakeTelegramMessenger + { + private const int BatchMessageId = 7001; + + public List Sends { get; } = []; + public List Edits { get; } = []; + public List DirectMessages { get; } = []; + public bool HasPublishedMessage => Sends.Count > 0; + public FakeTelegramMessage LastMessage => Edits.Count > 0 ? Edits[^1] : Sends[^1]; + + public void PublishBatchMessage(string text, InlineKeyboardMarkup markup) => + Sends.Add(new FakeTelegramMessage(BatchMessageId, text, markup)); + + public void EditBatchMessage(string text, InlineKeyboardMarkup markup) => + Edits.Add(new FakeTelegramMessage(BatchMessageId, text, markup)); + + public void SendDirectMessage(long telegramId, string text) => + DirectMessages.Add(new FakeDirectMessage(telegramId, text)); + } + + private sealed record FakeTelegramMessage( + int MessageId, + string Text, + InlineKeyboardMarkup Markup); + + private sealed record FakeDirectMessage(long TelegramId, string Text); + + private sealed record SmokeSession( + Guid Id, + DateTime ScheduledAt, + string Status, + int? MaxPlayers) + { + public DateTime ScheduledAt { get; set; } = ScheduledAt; + } + + private sealed record SmokeParticipant( + Guid SessionId, + Guid PlayerId, + long TelegramId, + string DisplayName, + string? TelegramUsername, + string RegistrationStatus, + string RsvpStatus, + int JoinOrder) + { + public string RegistrationStatus { get; set; } = RegistrationStatus; + public string RsvpStatus { get; set; } = RsvpStatus; + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs index 6c01f08..6a2a831 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -655,6 +655,10 @@ public sealed class AuthorizedSessionServiceTests public string? LastAddedCoGmUsername { get; private set; } public Guid? LastRemovedCoGmGroupId { get; private set; } public long? LastRemovedCoGmTelegramId { get; private set; } + public bool RemovePlayerCalled { get; private set; } + public Guid? LastRemovedPlayerSessionId { get; private set; } + public Guid? LastRemovedPlayerGroupId { get; private set; } + public Guid? LastRemovedPlayerParticipantId { get; private set; } public Task> GetGroupsForGmAsync(long gmId) => Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, gmId)).ToList()); @@ -870,6 +874,18 @@ public sealed class AuthorizedSessionServiceTests return Task.CompletedTask; } + public Task> GetSessionParticipantsAsync(Guid sessionId) => + Task.FromResult(new List()); + + public Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId) + { + RemovePlayerCalled = true; + LastRemovedPlayerSessionId = sessionId; + LastRemovedPlayerGroupId = groupId; + LastRemovedPlayerParticipantId = participantId; + return Task.CompletedTask; + } + private bool IsManager(Guid groupId, long telegramId) => IsOwner(groupId, telegramId) || managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId); diff --git a/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs b/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs index bdc80fc..cf5f0ed 100644 --- a/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs @@ -1,3 +1,5 @@ +using System.Xml.Linq; + namespace GmRelay.Bot.Tests.Web; public sealed class CampaignTemplatesNavigationTests @@ -11,6 +13,20 @@ public sealed class CampaignTemplatesNavigationTests Assert.Contains("Шаблоны", navMenu, StringComparison.Ordinal); } + [Fact] + public async Task NavMenu_ShouldExposeCurrentProjectVersion() + { + var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor")); + var props = XDocument.Load(FindRepositoryFile("Directory.Build.props")); + var version = props.Root? + .Element("PropertyGroup")? + .Element("Version")? + .Value; + + Assert.False(string.IsNullOrWhiteSpace(version)); + Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal); + } + [Fact] public async Task NavMenuStyles_ShouldStyleNavLinkAnchorsAsStackedRows() {