Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbd58142db | |||
| 956ec01583 | |||
| 5014ca5c58 | |||
| efd86bca0a | |||
| 2241568bac | |||
| 37ed697696 | |||
| 320ec18ab0 | |||
| 4424d8faad |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 3.9.6
|
VERSION: 3.9.9
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.9.6</Version>
|
<Version>3.9.9</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
|||||||
crond -f
|
crond -f
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.6
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.9
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.6
|
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.9
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -86,7 +86,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.6
|
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.9
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ using System.Linq;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
|
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
|
||||||
|
|
||||||
@@ -31,17 +33,23 @@ public sealed class CreateSessionHandler
|
|||||||
private readonly SharedCreateSessionHandler _shared;
|
private readonly SharedCreateSessionHandler _shared;
|
||||||
private readonly IWizardMessenger _messenger;
|
private readonly IWizardMessenger _messenger;
|
||||||
private readonly ILogger<CreateSessionHandler> _log;
|
private readonly ILogger<CreateSessionHandler> _log;
|
||||||
|
private readonly IPlatformMessenger? _platformMessenger;
|
||||||
|
private readonly NpgsqlDataSource? _dataSource;
|
||||||
|
|
||||||
public CreateSessionHandler(
|
public CreateSessionHandler(
|
||||||
IWizardDraftRepository drafts,
|
IWizardDraftRepository drafts,
|
||||||
SharedCreateSessionHandler shared,
|
SharedCreateSessionHandler shared,
|
||||||
IWizardMessenger messenger,
|
IWizardMessenger messenger,
|
||||||
ILogger<CreateSessionHandler> log)
|
ILogger<CreateSessionHandler> log,
|
||||||
|
IPlatformMessenger? platformMessenger = null,
|
||||||
|
NpgsqlDataSource? dataSource = null)
|
||||||
{
|
{
|
||||||
_drafts = drafts;
|
_drafts = drafts;
|
||||||
_shared = shared;
|
_shared = shared;
|
||||||
_messenger = messenger;
|
_messenger = messenger;
|
||||||
_log = log;
|
_log = log;
|
||||||
|
_platformMessenger = platformMessenger;
|
||||||
|
_dataSource = dataSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -106,19 +114,24 @@ public sealed class CreateSessionHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
var commands = BuildCommands(draft, payload);
|
var commands = BuildCommands(draft, payload);
|
||||||
|
var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var cmd in commands)
|
foreach (var cmd in commands)
|
||||||
{
|
{
|
||||||
await _shared.HandleAsync(cmd, ct);
|
var result = await _shared.HandleAsync(cmd, ct);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
await _messenger.EditDraftMessageAsync(
|
||||||
|
draft,
|
||||||
|
result.ErrorMessage ?? "❌ Не удалось создать сессию.",
|
||||||
|
Array.Empty<WizardAction>(),
|
||||||
|
ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
created.Add((cmd, result));
|
||||||
}
|
}
|
||||||
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
|
|
||||||
await _messenger.EditDraftMessageAsync(
|
|
||||||
draft,
|
|
||||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
|
||||||
Array.Empty<WizardAction>(),
|
|
||||||
ct);
|
|
||||||
await _drafts.DeleteAsync(draft.Id, ct);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -142,9 +155,89 @@ public sealed class CreateSessionHandler
|
|||||||
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
|
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
|
||||||
RetryCancelActions(),
|
RetryCancelActions(),
|
||||||
ct);
|
ct);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var totalSessions = created.Sum(c => c.Command.ScheduledTimes.Count);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var item in created)
|
||||||
|
{
|
||||||
|
await PublishCreatedSessionAsync(item.Command, item.Result, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex, "SubmitDraftAsync created draft {DraftId} but failed to publish schedule", draft.Id);
|
||||||
|
await _messenger.EditDraftMessageAsync(
|
||||||
|
draft,
|
||||||
|
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}, но не удалось опубликовать сообщение для записи: {ex.Message}",
|
||||||
|
Array.Empty<WizardAction>(),
|
||||||
|
ct);
|
||||||
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _messenger.EditDraftMessageAsync(
|
||||||
|
draft,
|
||||||
|
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
||||||
|
Array.Empty<WizardAction>(),
|
||||||
|
ct);
|
||||||
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PublishCreatedSessionAsync(CreateSessionCommand command, CreateSessionResult result, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_platformMessenger is null || _dataSource is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Session publication dependencies are not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.View is null || result.BatchId is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Created session result does not contain publication data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var group = command.Group;
|
||||||
|
var topicCreatedByBot = false;
|
||||||
|
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
|
||||||
|
{
|
||||||
|
var thread = await _platformMessenger.CreateThreadAsync(group, command.Title, ct);
|
||||||
|
group = group with { ExternalThreadId = thread.ExternalThreadId };
|
||||||
|
topicCreatedByBot = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scheduleMessage = await _platformMessenger.SendScheduleAsync(
|
||||||
|
new PlatformScheduleMessage(group, result.View, ExistingMessage: null, command.ImageReference),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||||
|
await connection.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET thread_id = @ThreadId,
|
||||||
|
batch_message_id = @BatchMessageId,
|
||||||
|
topic_created_by_bot = @TopicCreatedByBot,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE batch_id = @BatchId
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
result.BatchId,
|
||||||
|
ThreadId = ParseNullableInt(group.ExternalThreadId),
|
||||||
|
BatchMessageId = ParseInt(scheduleMessage.ExternalMessageId),
|
||||||
|
TopicCreatedByBot = topicCreatedByBot
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParseInt(string value) =>
|
||||||
|
int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static int? ParseNullableInt(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value)
|
||||||
|
? null
|
||||||
|
: int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
// ── Build shared commands ────────────────────────────────────────
|
// ── Build shared commands ────────────────────────────────────────
|
||||||
// The shared handler creates one session per scheduled time in a
|
// The shared handler creates one session per scheduled time in a
|
||||||
// single transaction and assigns the same batch_id to all of them.
|
// single transaction and assigns the same batch_id to all of them.
|
||||||
@@ -229,7 +322,8 @@ public sealed class CreateSessionHandler
|
|||||||
if (p.Type == WizardCreationType.Single)
|
if (p.Type == WizardCreationType.Single)
|
||||||
{
|
{
|
||||||
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
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
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,7 +82,13 @@ public sealed class CreateSessionHandler(
|
|||||||
AND p.external_user_id = @ExternalGmId
|
AND p.external_user_id = @ExternalGmId
|
||||||
ON CONFLICT (group_id, player_id) DO NOTHING
|
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);
|
transaction);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v3.9.6</div>
|
<div class="nav-version">v3.9.9</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
-14
@@ -1,19 +1,105 @@
|
|||||||
using System;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using Xunit;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Npgsql;
|
||||||
|
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
/// <summary>
|
[Collection(CreateSessionHandlerPostgresCollection.Name)]
|
||||||
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
|
public sealed class CreateSessionHandlerSubmitSingleDraftTests(CreateSessionHandlerPostgresFixture fixture)
|
||||||
/// on a single-game wizard payload. The success path calls the shared
|
|
||||||
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
|
|
||||||
/// <c>NpgsqlDataSource</c> (it runs SQL against game_groups, players,
|
|
||||||
/// sessions, and related tables). The missing-fields and validation
|
|
||||||
/// branches are covered by the dedicated tests in this folder.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CreateSessionHandlerSubmitSingleDraftTests
|
|
||||||
{
|
{
|
||||||
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")]
|
[Fact]
|
||||||
public void SubmitDraftAsync_CompleteSinglePayload_CreatesOneSession() =>
|
public async Task SubmitDraftAsync_CompleteSinglePayload_PublishesScheduleAndStoresMessageRefs()
|
||||||
throw new NotImplementedException("See Skip reason above.");
|
{
|
||||||
|
var connectionString = await fixture.CreateMigratedDatabaseAsync();
|
||||||
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
|
var drafts = new FakeWizardDraftRepository();
|
||||||
|
var wizardMessenger = new FakeWizardMessenger();
|
||||||
|
var platformMessenger = new FakePlatformMessenger();
|
||||||
|
|
||||||
|
var sut = new CreateSessionHandler(
|
||||||
|
drafts,
|
||||||
|
new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler(dataSource),
|
||||||
|
wizardMessenger,
|
||||||
|
NullLogger<CreateSessionHandler>.Instance,
|
||||||
|
platformMessenger,
|
||||||
|
dataSource);
|
||||||
|
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "Тест публикации",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||||
|
MaxPlayers = null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var draft = NewDraft(WizardStepNames.Confirm, payload, ownerId: 111111111);
|
||||||
|
draft.ChatId = "-1003916537960";
|
||||||
|
draft.DraftMessageId = "7";
|
||||||
|
drafts.Seed(draft);
|
||||||
|
|
||||||
|
await sut.SubmitDraftAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(platformMessenger.CreatedThreads);
|
||||||
|
Assert.Equal("Тест публикации", platformMessenger.CreatedThreads[0].Title);
|
||||||
|
Assert.Single(platformMessenger.SentSchedules);
|
||||||
|
Assert.Equal("456", platformMessenger.SentSchedules[0].Group.ExternalThreadId);
|
||||||
|
Assert.Contains(draft.Id, drafts.DeletedIds);
|
||||||
|
Assert.Contains(wizardMessenger.Edits, edit => edit.Text.Contains("✅ Создано: 1 сессия", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync();
|
||||||
|
await using var command = new NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
SELECT thread_id, batch_message_id, topic_created_by_bot
|
||||||
|
FROM sessions
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
connection);
|
||||||
|
await using var reader = await command.ExecuteReaderAsync();
|
||||||
|
Assert.True(await reader.ReadAsync());
|
||||||
|
Assert.Equal(456, reader.GetInt32(0));
|
||||||
|
Assert.Equal(789, reader.GetInt32(1));
|
||||||
|
Assert.True(reader.GetBoolean(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FakePlatformMessenger : IPlatformMessenger
|
||||||
|
{
|
||||||
|
public List<(PlatformGroup Group, string Title)> CreatedThreads { get; } = new();
|
||||||
|
|
||||||
|
public List<PlatformScheduleMessage> SentSchedules { get; } = new();
|
||||||
|
|
||||||
|
public Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
|
||||||
|
{
|
||||||
|
CreatedThreads.Add((group, title));
|
||||||
|
return Task.FromResult(new PlatformMessageRef(group.Platform, group.ExternalGroupId, "456", string.Empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
SentSchedules.Add(message);
|
||||||
|
return Task.FromResult(new PlatformMessageRef(
|
||||||
|
message.Group.Platform,
|
||||||
|
message.Group.ExternalGroupId,
|
||||||
|
message.Group.ExternalThreadId,
|
||||||
|
"789"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
+43
@@ -146,4 +146,47 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
|||||||
Assert.Single(messenger.Edits);
|
Assert.Single(messenger.Edits);
|
||||||
Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user