using System; using System.Text.Json; using GmRelay.Shared.Features.Sessions.CreateSession.Wizard; using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes; namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; /// /// Verifies the wizard's state machine: clicking each Choice callback should /// advance the draft to the expected next step and persist it. /// public sealed class GameCreationWizardStepTransitionsTests { [Theory] // Type → Title (single game) [InlineData(WizardStepNames.Type, "single", WizardStepNames.Title)] // Type → Title (pool) [InlineData(WizardStepNames.Type, "pool", WizardStepNames.Title)] // System → Duration (a known system code) [InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)] // Duration → DateTime (single, no maxPlayers yet) [InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)] // Capacity → Visibility (only explicit no-limit can skip numeric capacity) [InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Visibility)] // Visibility → Publish (public, no club) [InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)] // Visibility → PickClub [InlineData(WizardStepNames.Visibility, "club", WizardStepNames.PickClub)] [InlineData(WizardStepNames.Visibility, "members", WizardStepNames.PickClub)] [InlineData(WizardStepNames.Visibility, "pickclub", WizardStepNames.PickClub)] // Publish → Confirm [InlineData(WizardStepNames.Publish, "yes", WizardStepNames.Confirm)] [InlineData(WizardStepNames.Publish, "no", WizardStepNames.Confirm)] public async Task ChoiceCallback_AdvancesToExpectedStep( string fromStep, string choice, string expectedStep) { var wizard = BuildWizard(out var drafts, out _); var draft = NewDraft(fromStep, PayloadForStep(fromStep)); drafts.Seed(draft); var data = WizardCallbackData.Choice(fromStep, choice); await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(expectedStep, draft.Step); Assert.NotEmpty(drafts.Upserts); // was persisted } [Fact] public async Task PoolSystemDuration_PreselectedButton_AdvancesToVisibility() { var wizard = BuildWizard(out var drafts, out _); var payload = new WizardPayload { Type = WizardCreationType.Pool, Title = "Pool", }; var draft = NewDraft(WizardStepNames.PoolSystemDuration, payload); drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240"); await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Visibility, draft.Step); using var doc = JsonDocument.Parse(draft.PayloadJson); var root = doc.RootElement; Assert.True(root.TryGetProperty("system", out var sys)); Assert.Equal("Dnd5e", sys.GetString()); Assert.True(root.TryGetProperty("durationMinutes", out var dur)); Assert.Equal(240, dur.GetInt32()); } [Fact] public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull() { var wizard = BuildWizard(out var drafts, out _); var draft = NewDraft(WizardStepNames.Capacity, PayloadForStep(WizardStepNames.Capacity)); drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"); await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Visibility, draft.Step); using var doc = JsonDocument.Parse(draft.PayloadJson); var root = doc.RootElement; Assert.True(root.TryGetProperty("single", out var single)); // WizardPayloadJsonContext имеет DefaultIgnoreCondition=WhenWritingNull, // поэтому null-MaxPlayers просто не пишется. Оба варианта // (отсутствует / JsonValueKind.Null) десериализуются обратно в null // и уйдут в БД как NULL — то есть «без лимита». if (single.TryGetProperty("maxPlayers", out var maxPlayers)) { Assert.True( maxPlayers.ValueKind == JsonValueKind.Null, $"expected maxPlayers to be null (no limit), got {maxPlayers.ValueKind}"); } } [Fact] public async Task StaleCapacityWaitlistCallback_WithoutCapacity_StaysOnCurrentStep() { // A stale waitlist button from Capacity must not move a draft forward // unless MaxPlayers is already set. Otherwise users can reach Confirm // with a missing capacity and get "Не заполнены поля: лимит мест". var wizard = BuildWizard(out var drafts, out _); var draft = NewDraft(WizardStepNames.System); drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"); await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.System, draft.Step); } [Fact] public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick() { var wizard = BuildWizard(out var drafts, out _); var clubId = Guid.NewGuid(); var payload = new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240, Visibility = WizardVisibility.Club, }; var draft = NewDraft(WizardStepNames.PickClub, payload); drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString()); await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.Publish, draft.Step); using var doc = JsonDocument.Parse(draft.PayloadJson); var root = doc.RootElement; Assert.True(root.TryGetProperty("clubId", out var clubIdJson)); Assert.Equal(clubId, clubIdJson.GetGuid()); } [Fact] public async Task PickClub_InvalidGuid_StaysOnPickClub() { var wizard = BuildWizard(out var drafts, out _); var payload = new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240, Visibility = WizardVisibility.Club, }; var draft = NewDraft(WizardStepNames.PickClub, payload); drafts.Seed(draft); var data = WizardCallbackData.Choice(WizardStepNames.PickClub, "not-a-guid"); await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None); Assert.Equal(WizardStepNames.PickClub, draft.Step); } /// /// Builds a payload that already contains the values the wizard expects to /// be set when the user is sitting on a given step. Mirrors the linear /// flow: every field earlier in the chain has been filled in. /// private static WizardPayload PayloadForStep(string step) => step switch { WizardStepNames.Type or WizardStepNames.Title => new WizardPayload(), WizardStepNames.System => new WizardPayload { Type = WizardCreationType.Single, Title = "T" }, WizardStepNames.Duration => new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" }, WizardStepNames.Capacity => new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240, Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) }, }, WizardStepNames.Visibility => new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240, }, WizardStepNames.PickClub => new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240, Visibility = WizardVisibility.Club, }, WizardStepNames.Publish => new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240, Visibility = WizardVisibility.Public, }, WizardStepNames.Confirm => new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e", DurationMinutes = 240, Visibility = WizardVisibility.Public, }, _ => new WizardPayload(), }; }