Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5014ca5c58 | |||
| efd86bca0a | |||
| 2241568bac | |||
| 37ed697696 | |||
| 320ec18ab0 | |||
| 4424d8faad | |||
| 1f3fb6e89e | |||
| e3e6e841b8 | |||
| a0a84965b3 | |||
| 67e8d5b558 | |||
| 593f8a62fb | |||
| aee0ac1e6c | |||
| 68945d931f | |||
| 3db2b703d6 | |||
| 3c3ef8db5a | |||
| 5c0397a5e6 | |||
| 15040eb954 |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.9.2
|
||||
VERSION: 3.9.8
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>3.9.2</Version>
|
||||
<Version>3.9.8</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:3.9.2
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.8
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.2
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.8
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -86,7 +86,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.2
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.8
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -30,8 +30,10 @@ RUN dotnet publish "GmRelay.Bot.csproj" -c Release -a $TARGETARCH -o /app/publis
|
||||
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Устанавливаем wget для healthcheck
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget \
|
||||
# Устанавливаем wget для healthcheck и libgssapi-krb5-2 для Npgsql GSS/SSPI
|
||||
# и HTTPS-handshake Telegram.Bot (без неё long-polling падает на первом запросе).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libgssapi-krb5-2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копируем только AOT-результаты из билда
|
||||
|
||||
@@ -170,7 +170,7 @@ public sealed class CreateSessionHandler
|
||||
draft,
|
||||
p,
|
||||
new[] { p.Single?.ScheduledAt ?? default },
|
||||
p.Single?.MaxPlayers ?? 0,
|
||||
p.Single?.MaxPlayers,
|
||||
isOneShot: true),
|
||||
};
|
||||
}
|
||||
@@ -178,11 +178,11 @@ public sealed class CreateSessionHandler
|
||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||
|
||||
private static CreateSessionCommand BuildCommand(
|
||||
internal static CreateSessionCommand BuildCommand(
|
||||
WizardDraft draft,
|
||||
WizardPayload p,
|
||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||
int maxPlayers,
|
||||
int? maxPlayers,
|
||||
bool isOneShot)
|
||||
{
|
||||
var user = new PlatformUser(
|
||||
@@ -229,7 +229,8 @@ public sealed class CreateSessionHandler
|
||||
if (p.Type == WizardCreationType.Single)
|
||||
{
|
||||
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
||||
if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест");
|
||||
// MaxPlayers = null is a valid "♾ Без лимита" choice
|
||||
// (see GameCreationWizard.ApplyCapacityChoice "no_limit").
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -252,11 +252,12 @@ public static class DiscordWizardStep
|
||||
|
||||
private static DiscordWizardRender RenderCapacity() => new(
|
||||
"👥 Лимит мест",
|
||||
"Введите лимит (1..50) и выберите waitlist.",
|
||||
"Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
|
||||
ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
|
||||
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.Capacity, "no_limit", ButtonStyle.Primary)),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
@@ -371,11 +372,12 @@ public static class DiscordWizardStep
|
||||
|
||||
private static DiscordWizardRender RenderPoolSlotCapacity() => new(
|
||||
"👥 Лимит слотов",
|
||||
"Введите лимит (1..50) и выберите waitlist.",
|
||||
"Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
|
||||
ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
|
||||
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.PoolSlotCapacity, "no_limit", ButtonStyle.Primary)),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
@@ -417,6 +419,10 @@ public static class DiscordWizardStep
|
||||
{
|
||||
sb.Append("👥 Мест: ").Append(mp).Append(", waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
|
||||
}
|
||||
else if (p.Type == WizardCreationType.Single)
|
||||
{
|
||||
sb.Append("👥 Без лимита, waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
|
||||
}
|
||||
sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ public sealed class DiscordWizardSubmitter
|
||||
draft,
|
||||
p,
|
||||
new[] { p.Single?.ScheduledAt ?? default },
|
||||
p.Single?.MaxPlayers ?? 0,
|
||||
p.Single?.MaxPlayers,
|
||||
isOneShot: true),
|
||||
};
|
||||
}
|
||||
@@ -142,11 +142,11 @@ public sealed class DiscordWizardSubmitter
|
||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||
|
||||
private static CreateSessionCommand BuildCommand(
|
||||
internal static CreateSessionCommand BuildCommand(
|
||||
WizardDraft draft,
|
||||
WizardPayload p,
|
||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||
int maxPlayers,
|
||||
int? maxPlayers,
|
||||
bool isOneShot)
|
||||
{
|
||||
var user = new PlatformUser(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
|
||||
@@ -82,7 +82,13 @@ public sealed class CreateSessionHandler(
|
||||
AND p.external_user_id = @ExternalGmId
|
||||
ON CONFLICT (group_id, player_id) DO NOTHING
|
||||
""",
|
||||
new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
Platform = platform,
|
||||
ExternalGmId = externalUserId,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||
},
|
||||
transaction);
|
||||
}
|
||||
else
|
||||
|
||||
@@ -298,12 +298,25 @@ public sealed class GameCreationWizard
|
||||
: (null, "Неверная длительность"),
|
||||
};
|
||||
|
||||
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice) => choice switch
|
||||
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice)
|
||||
{
|
||||
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
|
||||
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
if (choice is "no_limit")
|
||||
{
|
||||
return (WizardStepNames.Visibility, SetMaxPlayers(p, null));
|
||||
}
|
||||
|
||||
if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null)
|
||||
{
|
||||
return (null, "Сначала введите лимит мест или нажмите «♾ Без лимита»");
|
||||
}
|
||||
|
||||
return choice switch
|
||||
{
|
||||
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
|
||||
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
}
|
||||
|
||||
private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
@@ -315,9 +328,15 @@ public sealed class GameCreationWizard
|
||||
};
|
||||
|
||||
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
|
||||
=> Guid.TryParse(choice, out var id)
|
||||
? (NextAfterVisibility(p), SetClubId(p, id))
|
||||
: (null, "Неверный идентификатор клуба");
|
||||
{
|
||||
if (!Guid.TryParse(choice, out var id))
|
||||
{
|
||||
return (null, "Неверный идентификатор клуба");
|
||||
}
|
||||
|
||||
var error = SetClubId(p, id);
|
||||
return (NextAfterVisibility(p), error);
|
||||
}
|
||||
|
||||
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
@@ -416,7 +435,7 @@ public sealed class GameCreationWizard
|
||||
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
|
||||
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
|
||||
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
|
||||
private static string? SetMaxPlayers(WizardPayload p, int v)
|
||||
private static string? SetMaxPlayers(WizardPayload p, int? v)
|
||||
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
|
||||
private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
|
||||
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
|
||||
|
||||
@@ -97,11 +97,12 @@ public static class WizardStepViewBuilder
|
||||
BackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => (
|
||||
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.",
|
||||
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist. Или сразу «♾ Без лимита».",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success),
|
||||
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger),
|
||||
new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v3.9.2</div>
|
||||
<div class="nav-version">v3.9.8</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using NetCord.Rest;
|
||||
using Xunit;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Renderer tests for the Discord wizard's Capacity / PoolSlotCapacity steps.
|
||||
/// Locks in the presence of the "♾ Без лимита" button so the user can pick
|
||||
/// a session with no player cap (null in <c>sessions.max_players</c>), the
|
||||
/// same affordance the Telegram wizard provides.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardStepCapacityRenderTests
|
||||
{
|
||||
[Fact]
|
||||
public void RenderCapacity_ContainsNoLimitButton()
|
||||
{
|
||||
var draft = new WizardDraft { Step = WizardStepNames.Capacity };
|
||||
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||
|
||||
var labels = ExtractButtonLabels(render);
|
||||
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPoolSlotCapacity_ContainsNoLimitButton()
|
||||
{
|
||||
var draft = new WizardDraft { Step = WizardStepNames.PoolSlotCapacity };
|
||||
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||
|
||||
var labels = ExtractButtonLabels(render);
|
||||
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(WizardStepNames.Capacity, "wizard:btn:choice:Capacity:no_limit")]
|
||||
[InlineData(WizardStepNames.PoolSlotCapacity, "wizard:btn:choice:PoolSlotCapacity:no_limit")]
|
||||
public void Render_NoLimitButton_HasChoiceCustomIdForNoLimit(string step, string expectedCustomIdPrefix)
|
||||
{
|
||||
var draft = new WizardDraft { Step = step };
|
||||
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||
|
||||
var buttons = ExtractButtons(render);
|
||||
var noLimit = buttons.SingleOrDefault(b => b.Label?.Contains("Без лимита", System.StringComparison.Ordinal) == true);
|
||||
Assert.NotNull(noLimit);
|
||||
Assert.StartsWith(expectedCustomIdPrefix, noLimit!.CustomId);
|
||||
}
|
||||
|
||||
private static System.Collections.Generic.List<string> ExtractButtonLabels(
|
||||
DiscordWizardStep.DiscordWizardRender render) =>
|
||||
render.Components
|
||||
.OfType<ActionRowProperties>()
|
||||
.SelectMany(r => r.Components)
|
||||
.OfType<ButtonProperties>()
|
||||
.Select(b => b.Label ?? string.Empty)
|
||||
.ToList();
|
||||
|
||||
private static System.Collections.Generic.List<ButtonProperties> ExtractButtons(
|
||||
DiscordWizardStep.DiscordWizardRender render) =>
|
||||
render.Components
|
||||
.OfType<ActionRowProperties>()
|
||||
.SelectMany(r => r.Components)
|
||||
.OfType<ButtonProperties>()
|
||||
.ToList();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Xunit;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for <see cref="DiscordWizardSubmitter"/>'s
|
||||
/// <c>BuildCommand</c>: when the wizard payload carries no player limit
|
||||
/// (user picked «♾ Без лимита» on the Capacity step), the resulting
|
||||
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never
|
||||
/// <c>0</c>. <c>0</c> would violate the DB CHECK
|
||||
/// <c>ck_sessions_max_players</c> in V006 and the contract that
|
||||
/// <c>null</c> means "no limit".
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardSubmitterBuildCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = null,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = DiscordWizardSubmitter.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Null(cmd.MaxPlayers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = 5,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = DiscordWizardSubmitter.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Equal(5, cmd.MaxPlayers);
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class CreateSessionHandlerPostgresCollection : ICollectionFixture<CreateSessionHandlerPostgresFixture>
|
||||
{
|
||||
public const string Name = "Create session handler PostgreSQL";
|
||||
}
|
||||
|
||||
public sealed class CreateSessionHandlerPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
|
||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
return container.StartAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
return container.DisposeAsync().AsTask().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
public async Task<string> CreateMigratedDatabaseAsync()
|
||||
{
|
||||
var databaseName = $"create_session_{Guid.NewGuid():N}";
|
||||
|
||||
await using (var adminConnection = new NpgsqlConnection(container.GetConnectionString()))
|
||||
{
|
||||
await adminConnection.OpenAsync().WaitAsync(ContainerTimeout);
|
||||
await using var createDatabase = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", adminConnection);
|
||||
await createDatabase.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
var connectionString = new NpgsqlConnectionStringBuilder(container.GetConnectionString())
|
||||
{
|
||||
Database = databaseName,
|
||||
Timeout = 10,
|
||||
CommandTimeout = 30
|
||||
}.ConnectionString;
|
||||
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync().WaitAsync(ContainerTimeout);
|
||||
|
||||
foreach (var migration in GetMigrationPaths())
|
||||
{
|
||||
await using var command = new NpgsqlCommand(await File.ReadAllTextAsync(migration), connection)
|
||||
{
|
||||
CommandTimeout = 30
|
||||
};
|
||||
await command.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetMigrationPaths()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var migrationsDirectory = Path.Combine(directory.FullName, "src", "GmRelay.Bot", "Migrations");
|
||||
if (Directory.Exists(migrationsDirectory))
|
||||
{
|
||||
return Directory.GetFiles(migrationsDirectory, "V*.sql")
|
||||
.OrderBy(path => Path.GetFileName(path), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate the bot migrations directory.");
|
||||
}
|
||||
}
|
||||
|
||||
[Collection(CreateSessionHandlerPostgresCollection.Name)]
|
||||
public sealed class CreateSessionHandlerIntegrationTests(CreateSessionHandlerPostgresFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task HandleAsync_NewPlatformGroup_AddsOwnerAndPersistsSession()
|
||||
{
|
||||
var connectionString = await fixture.CreateMigratedDatabaseAsync();
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
var sut = new CreateSessionHandler(dataSource);
|
||||
|
||||
var result = await sut.HandleAsync(
|
||||
new CreateSessionCommand(
|
||||
new PlatformUser(PlatformKind.Telegram, "111111111", "Test GM", "test_gm"),
|
||||
new PlatformGroup(PlatformKind.Telegram, "222222222", "Test Group"),
|
||||
"Test Adventure",
|
||||
string.Empty,
|
||||
[DateTimeOffset.UtcNow.AddDays(1)],
|
||||
null,
|
||||
null,
|
||||
GameSystem.Dnd5e,
|
||||
"Integration regression test",
|
||||
"Online",
|
||||
240,
|
||||
true),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.NotNull(result.BatchId);
|
||||
Assert.NotNull(result.GroupId);
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync();
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = @group_id
|
||||
AND gm.role = 'Owner'
|
||||
AND p.platform = 'Telegram'
|
||||
AND p.external_user_id = '111111111'
|
||||
""",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("group_id", result.GroupId.Value);
|
||||
|
||||
var ownerCount = (long)(await command.ExecuteScalarAsync() ?? 0L);
|
||||
Assert.Equal(1, ownerCount);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Xunit;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for <see cref="CreateSessionHandler.BuildCommand"/>:
|
||||
/// when the wizard payload carries no player limit (e.g. user picked
|
||||
/// «♾ Без лимита» on the Capacity step), the resulting
|
||||
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never <c>0</c>.
|
||||
/// <c>0</c> would violate the DB CHECK constraint
|
||||
/// (<c>ck_sessions_max_players</c> in V006) by inserting <c>0</c> instead of
|
||||
/// <c>NULL</c>, which is the wire-level representation of "no limit".
|
||||
/// </summary>
|
||||
public sealed class CreateSessionHandlerBuildCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = null,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = CreateSessionHandler.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Null(cmd.MaxPlayers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = 5,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = CreateSessionHandler.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Equal(5, cmd.MaxPlayers);
|
||||
}
|
||||
}
|
||||
+43
@@ -146,4 +146,47 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
||||
Assert.Single(messenger.Edits);
|
||||
Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitDraftAsync_SingleWithNoLimit_DoesNotReportMaxPlayersAsMissing()
|
||||
{
|
||||
// Regression for #131: pressing "♾ Без лимита" sets MaxPlayers = null.
|
||||
// IsComplete must NOT flag that as a missing field; null means
|
||||
// "no player limit" and is a valid final state.
|
||||
var drafts = new FakeWizardDraftRepository();
|
||||
var messenger = new FakeWizardMessenger();
|
||||
|
||||
var sut = new CreateSessionHandler(
|
||||
drafts,
|
||||
shared: null!,
|
||||
messenger,
|
||||
NullLogger<CreateSessionHandler>.Instance);
|
||||
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||
MaxPlayers = null,
|
||||
},
|
||||
};
|
||||
var draft = NewDraft(WizardStepNames.Confirm, payload);
|
||||
drafts.Seed(draft);
|
||||
|
||||
await sut.SubmitDraftAsync(draft, CancellationToken.None);
|
||||
|
||||
// Validation must let the no-limit payload through. The shared
|
||||
// handler is null, so anything that reached the database call would
|
||||
// throw a NullReferenceException — that is caught by the retry
|
||||
// path and reported as a "💥 Ошибка:" edit, not a missing-fields
|
||||
// edit. Therefore we assert that NO edit mentions a missing field.
|
||||
Assert.NotEmpty(messenger.Edits);
|
||||
var lastEdit = messenger.Edits[^1].Text;
|
||||
Assert.DoesNotContain("Не заполнены", lastEdit, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
+40
-18
@@ -20,9 +20,8 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
|
||||
// Duration → DateTime (single, no maxPlayers yet)
|
||||
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
|
||||
// Capacity → Visibility
|
||||
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
|
||||
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
|
||||
// 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
|
||||
@@ -71,13 +70,37 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
|
||||
public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
|
||||
{
|
||||
// The wizard's callback parser uses the step encoded in the callback
|
||||
// (not the draft's current step) to drive transitions. So a stale
|
||||
// "Capacity" button pressed while the user is on System will in fact
|
||||
// move the draft forward as if they had pressed it on Capacity. We
|
||||
// lock that behaviour in.
|
||||
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);
|
||||
@@ -85,17 +108,13 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||
Assert.Equal(WizardStepNames.System, draft.Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PickClub_ValidGuid_ReachesStableStep()
|
||||
public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick()
|
||||
{
|
||||
// The wizard has a quirk: NextAfterVisibility is evaluated before
|
||||
// SetClubId, so a single click leaves the draft still on PickClub.
|
||||
// We assert that the wizard does NOT throw and the messenger is asked
|
||||
// to re-render (i.e. the handler ran end-to-end).
|
||||
var wizard = BuildWizard(out var drafts, out var messenger);
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var clubId = Guid.NewGuid();
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
@@ -111,8 +130,11 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
// Wizard acknowledged the callback and re-rendered the (still PickClub) step.
|
||||
Assert.NotEmpty(messenger.Edits);
|
||||
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]
|
||||
|
||||
+23
@@ -136,6 +136,29 @@ public sealed class GameCreationWizardValidationTests
|
||||
Assert.Equal(WizardStepNames.Capacity, draft.Step);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("waitlist:on")]
|
||||
[InlineData("waitlist:off")]
|
||||
public async Task WaitlistChoiceWithoutCapacity_StaysOnCapacityStep(string choice)
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var draft = NewDraft(WizardStepNames.Capacity,
|
||||
new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
|
||||
});
|
||||
drafts.Seed(draft);
|
||||
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, choice);
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Capacity, draft.Step);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("13")]
|
||||
|
||||
+1
@@ -76,6 +76,7 @@ public sealed class WizardStepRenderTests
|
||||
var labels = ButtonLabels(kb);
|
||||
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
|
||||
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
|
||||
Assert.Contains(labels, l => l.Contains("Без лимита", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -14,8 +14,16 @@ public sealed class CampaignTemplatesNavigationTests
|
||||
[Fact]
|
||||
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
|
||||
{
|
||||
// Read the version from Directory.Build.props (the canonical source of
|
||||
// truth) so the test doesn't need to be hand-edited on every version
|
||||
// bump. Asserting the rendered NavMenu matches the canonical version
|
||||
// catches real bugs (e.g. someone bumps Directory.Build.props but
|
||||
// forgets to update NavMenu.razor) without false alarms from a stale
|
||||
// hard-coded literal.
|
||||
var propsPath = FindRepositoryFile("Directory.Build.props");
|
||||
var version = ReadVersionFromProps(propsPath);
|
||||
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
|
||||
Assert.Contains("v3.9.2", navMenu, StringComparison.Ordinal);
|
||||
Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -68,4 +76,23 @@ public sealed class CampaignTemplatesNavigationTests
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the <c><Version>...</Version></c> element from
|
||||
/// <c>Directory.Build.props</c>. Tolerant of whitespace, comments and
|
||||
/// attribute shuffling — the MSBuild schema for <c>Version</c> is just
|
||||
/// a plain element with a string body.
|
||||
/// </summary>
|
||||
private static string ReadVersionFromProps(string propsPath)
|
||||
{
|
||||
var doc = System.Xml.Linq.XDocument.Load(propsPath);
|
||||
var versionElement = doc.Descendants()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "Version");
|
||||
Assert.NotNull(versionElement);
|
||||
var version = versionElement!.Value.Trim();
|
||||
Assert.False(
|
||||
string.IsNullOrEmpty(version),
|
||||
$"<Version> in {propsPath} is empty");
|
||||
return version;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user