014b5edd31
PR Checks / test-and-build (pull_request) Successful in 15m52s
Add format and location steps to the Telegram /newsession wizard, persist offline addresses in sessions.location_address, and render online links/offline addresses in schedule messages. Bump version to 3.10.0.
295 lines
13 KiB
C#
295 lines
13 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Verifies the wizard's state machine: clicking each Choice callback should
|
|
/// advance the draft to the expected next step and persist it.
|
|
/// </summary>
|
|
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 → Format (only explicit no-limit can skip numeric capacity)
|
|
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Format)]
|
|
// 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_AdvancesToFormat()
|
|
{
|
|
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.Format, 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.Format, 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 Format_OnlineChoice_AdvancesToLocationAndPersistsFormat()
|
|
{
|
|
var wizard = BuildWizard(out var drafts, out _);
|
|
var draft = NewDraft(WizardStepNames.Format, PayloadForStep(WizardStepNames.Format));
|
|
drafts.Seed(draft);
|
|
|
|
var data = WizardCallbackData.Choice(WizardStepNames.Format, "online");
|
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
|
|
|
Assert.Equal(WizardStepNames.Location, draft.Step);
|
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
|
var root = doc.RootElement;
|
|
Assert.True(root.TryGetProperty("format", out var format));
|
|
Assert.Equal("Online", format.GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Format_OfflineChoice_AdvancesToLocationAndPersistsFormat()
|
|
{
|
|
var wizard = BuildWizard(out var drafts, out _);
|
|
var draft = NewDraft(WizardStepNames.Format, PayloadForStep(WizardStepNames.Format));
|
|
drafts.Seed(draft);
|
|
|
|
var data = WizardCallbackData.Choice(WizardStepNames.Format, "offline");
|
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
|
|
|
Assert.Equal(WizardStepNames.Location, draft.Step);
|
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
|
var root = doc.RootElement;
|
|
Assert.True(root.TryGetProperty("format", out var format));
|
|
Assert.Equal("Offline", format.GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Location_TextForOnline_StoresJoinLinkAndAdvancesToVisibility()
|
|
{
|
|
var wizard = BuildWizard(out var drafts, out _);
|
|
var payload = PayloadForStep(WizardStepNames.Location);
|
|
payload.Format = WizardSessionFormat.Online;
|
|
var draft = NewDraft(WizardStepNames.Location, payload);
|
|
drafts.Seed(draft);
|
|
|
|
await wizard.HandleInteractionAsync(TextInteraction("https://vtt.example/game", 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("joinLink", out var joinLink));
|
|
Assert.Equal("https://vtt.example/game", joinLink.GetString());
|
|
Assert.False(root.TryGetProperty("locationAddress", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Location_TextForOffline_StoresAddressAndAdvancesToVisibility()
|
|
{
|
|
var wizard = BuildWizard(out var drafts, out _);
|
|
var payload = PayloadForStep(WizardStepNames.Location);
|
|
payload.Format = WizardSessionFormat.Offline;
|
|
var draft = NewDraft(WizardStepNames.Location, payload);
|
|
drafts.Seed(draft);
|
|
|
|
await wizard.HandleInteractionAsync(TextInteraction("Москва, ул. Кубиков, 12", 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("locationAddress", out var address));
|
|
Assert.Equal("Москва, ул. Кубиков, 12", address.GetString());
|
|
Assert.False(root.TryGetProperty("joinLink", out _));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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,
|
|
Format = WizardSessionFormat.Online,
|
|
JoinLink = "https://vtt.example/game",
|
|
},
|
|
WizardStepNames.Format or WizardStepNames.Location => new WizardPayload
|
|
{
|
|
Type = WizardCreationType.Single,
|
|
Title = "T",
|
|
System = "Dnd5e",
|
|
DurationMinutes = 240,
|
|
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
|
|
},
|
|
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(),
|
|
};
|
|
}
|