Compare commits

..

13 Commits

Author SHA1 Message Date
Toutsu 5014ca5c58 Merge pull request #134: fix(shared): bind platform when creating group manager (v3.9.8)
Deploy Telegram Bot / build-and-push (push) Successful in 8m46s
Deploy Telegram Bot / scan-images (push) Successful in 2m26s
Deploy Telegram Bot / deploy (push) Successful in 56s
2026-06-09 15:41:19 +03:00
Toutsu efd86bca0a fix(shared): bind platform when creating group manager
PR Checks / test-and-build (pull_request) Successful in 12m53s
Add a PostgreSQL integration regression test for new-platform-group session creation. The production failure was a missing Platform parameter in the group_managers insert, leaving @Platform in SQL and causing PostgreSQL 42883. Bump version to 3.9.8.
2026-06-09 15:16:54 +03:00
Toutsu 2241568bac Merge pull request #132: fix(bot): IsComplete must not flag null MaxPlayers as missing (no-limit) (v3.9.7)
Deploy Telegram Bot / build-and-push (push) Successful in 8m41s
Deploy Telegram Bot / scan-images (push) Successful in 3m0s
Deploy Telegram Bot / deploy (push) Successful in 1m10s
Closes #131.
2026-06-09 13:34:37 +03:00
Toutsu 37ed697696 fix(bot): trim misleading comment in IsComplete
PR Checks / test-and-build (pull_request) Successful in 12m9s
The previous comment claimed a 0 MaxPlayers would also be blocked, but
the code never actually enforced that. The wizard's text-input path
already guarantees cap >= MinCapacity, so 0 is unreachable. Drop the
inaccurate half-sentence to keep the comment faithful to the code.
2026-06-09 13:33:53 +03:00
Toutsu 320ec18ab0 fix(bot): IsComplete must not flag null MaxPlayers as missing (no-limit)
PR Checks / test-and-build (pull_request) Successful in 12m33s
After 3.9.6 fixed long-polling, the bot finally reaches the final
' Создать' step. Users pressing '♾ Без лимита' on the Capacity step
get a valid payload where Single.MaxPlayers = null (the legitimate
no-limit choice from GameCreationWizard.ApplyCapacityChoice 'no_limit'),
but CreateSessionHandler.IsComplete then reports 'лимит мест' as
missing, blocking session creation.

This regression existed since 3.9.3 (when 'no_limit' was added) but
stayed invisible because 3.9.4 and 3.9.5 never reached SubmitDraft
(libgssapi-krb5 missing → long-polling hung). Once 3.9.6 restored
polling, the bug surfaced immediately.

Fix: drop the null-MaxPlayers check from IsComplete for Single type.
Null is a valid 'no limit' state and must pass through to BuildCommands
→ shared handler, which already accepts null MaxPlayers correctly.

Closes #131.

Bump version 3.9.6 -> 3.9.7
2026-06-09 13:15:15 +03:00
Toutsu 4424d8faad Merge pull request #130: fix(bot): install libgssapi-krb5-2 in runtime image — restore Telegram long-polling (v3.9.6)
Deploy Telegram Bot / build-and-push (push) Successful in 7m46s
Deploy Telegram Bot / scan-images (push) Successful in 2m46s
Deploy Telegram Bot / deploy (push) Successful in 57s
Closes #129.
2026-06-09 12:41:39 +03:00
Toutsu 1f3fb6e89e fix(bot): install libgssapi-krb5-2 in runtime image
PR Checks / test-and-build (pull_request) Successful in 13m32s
Telegram bot's long-polling hangs after the first GetUpdates request
because libgssapi-krb5.so.2 is missing from the runtime-deps:10.0-noble
final image. .NET runtime attempts dlopen() of libgssapi during the
HTTPS handshake; without the library the HttpClient connection pool
enters an unrecoverable state and TelegramBotService never receives
new updates, even though SessionSchedulerService keeps sending
outgoing messages successfully.

Symptom (Loki, container gmrelaybot-bot-1):
  Telegram bot polling started
  Polling error, retrying in 5s
  Telegram.Bot.Exceptions.RequestException: Bot API Service Failure
  Cannot load library libgssapi_krb5.so.2

After the single Polling error, no Error handling update, no further
Polling error, and getUpdates from outside returns [] forever.

Fix: install libgssapi-krb5-2 alongside wget in the final stage of
src/GmRelay.Bot/Dockerfile. This also future-proofs Npgsql GSS/SSPI
Kerberos authentication for PostgreSQL.

Closes #129.

Bump version 3.9.5 -> 3.9.6
2026-06-09 12:20:32 +03:00
Toutsu e3e6e841b8 Merge pull request #128: fix(bot): keep Capacity and PickClub wizard steps consistent (v3.9.5)
Deploy Telegram Bot / build-and-push (push) Successful in 7m9s
Deploy Telegram Bot / scan-images (push) Successful in 2m23s
Deploy Telegram Bot / deploy (push) Successful in 54s
Closes #127.
2026-06-08 22:51:04 +03:00
Toutsu a0a84965b3 chore: bump version 3.9.4 -> 3.9.5
PR Checks / test-and-build (pull_request) Successful in 10m22s
Bugfix patch release for issue #127. Sync the four canonical version sources:
- Directory.Build.props
- compose.yaml (bot, discord, web image tags)
- .gitea/workflows/deploy.yml (VERSION env)
- src/GmRelay.Web/Components/Layout/NavMenu.razor (visible nav-version)
2026-06-08 22:34:27 +03:00
Toutsu 67e8d5b558 fix(bot): keep capacity and club wizard steps consistent
Fix two wizard FSM bugs reported after v3.9.4:

1. Capacity waitlist buttons could still advance the draft without a
   numeric MaxPlayers value. The final submit validation then rejected
   the draft with 'Не заполнены поля: лимит мест'. Now waitlist:on/off
   stay on Capacity until MaxPlayers is set; users must either enter a
   numeric limit or explicitly choose '♾ Без лимита'.

2. PickClub computed NextAfterVisibility before SetClubId, so the first
   club click left the wizard on PickClub and the second click advanced.
   Now ClubId is saved first and NextAfterVisibility is evaluated after
   that mutation, so a valid club click advances on the first try.

TDD:
- WaitlistChoiceWithoutCapacity_StaysOnCapacityStep covers waitlist:on/off.
- PickClub_ValidGuid_AdvancesToPublishOnFirstClick covers the single-click club path.
- Stale Capacity waitlist callback test updated to the safer no-advance contract.

Closes #127
2026-06-08 22:34:18 +03:00
Toutsu 593f8a62fb Merge pull request #126: test: cleanup follow-up from PR #124 review (v3.9.4)
Deploy Telegram Bot / build-and-push (push) Successful in 6m15s
Deploy Telegram Bot / scan-images (push) Successful in 2m20s
Deploy Telegram Bot / deploy (push) Successful in 46s
Closes #125.
2026-06-08 19:27:58 +03:00
Toutsu aee0ac1e6c chore: bump version 3.9.3 -> 3.9.4
PR Checks / test-and-build (pull_request) Successful in 10m23s
Test-only patch release. Sync the four canonical version sources:
- Directory.Build.props
- compose.yaml (bot, discord, web image tags)
- .gitea/workflows/deploy.yml (VERSION env)
- src/GmRelay.Web/Components/Layout/NavMenu.razor (visible nav-version)

The new NavMenu_ShouldExposeCurrentProjectVersion test reads the
version from Directory.Build.props, so this bump does NOT need a
hand-edited test literal — verified locally that the test passes
on 3.9.4 with no manual changes.
2026-06-08 19:11:31 +03:00
Toutsu 68945d931f test: cleanup follow-up from PR #124 review
Two non-blocking suggestions from the PR #124 code review:

1. Symmetric coverage for the Discord PoolSlotCapacity no-limit button.
   The Capacity step already had a customId-shape assertion for the
   '♾ Без лимита' button; PoolSlotCapacity only had a label-presence
   assertion. If ChoiceButtonCustomId's wire format ever diverged between
   the two steps, only Capacity would catch it. Convert the single Fact
   to a Theory with two InlineData rows so a regression in either step
   produces a targeted test failure.

2. Brittle hard-coded version literal in NavMenu_ShouldExposeCurrent
   ProjectVersion. The test had to be hand-edited on every version bump
   (we hit this in PR #124 — bumping 3.9.2 -> 3.9.3 broke CI). Read the
   version from Directory.Build.props via XDocument instead so the test
   only fails when the rendered NavMenu actually disagrees with the
   canonical version, not when someone forgot to update a literal.
   Tolerant of whitespace, comments and attribute order; the <Version>
   element is a plain string body in the MSBuild schema.

No production code changes; production version is bumped in a
follow-up commit.

Closes #125
2026-06-08 19:11:28 +03:00
14 changed files with 289 additions and 42 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.9.3
VERSION: 3.9.8
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.9.3</Version>
<Version>3.9.8</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.3
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.3
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.3
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.8
restart: always
depends_on:
db:
+4 -2
View File
@@ -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-результаты из билда
@@ -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
{
@@ -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,13 +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)),
"no_limit" => (WizardStepNames.Visibility, SetMaxPlayers(p, null)),
_ => (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
{
@@ -316,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
{
@@ -82,7 +82,7 @@
</button>
</form>
<div class="nav-version">v3.9.3</div>
<div class="nav-version">v3.9.8</div>
</div>
</Authorized>
<NotAuthorized>
@@ -33,16 +33,18 @@ public sealed class DiscordWizardStepCapacityRenderTests
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
}
[Fact]
public void RenderCapacity_NoLimitButton_HasChoiceCustomIdForNoLimit()
[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 = WizardStepNames.Capacity };
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("wizard:btn:choice:Capacity:no_limit", noLimit!.CustomId);
Assert.StartsWith(expectedCustomIdPrefix, noLimit!.CustomId);
}
private static System.Collections.Generic.List<string> ExtractButtonLabels(
@@ -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);
}
}
@@ -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);
}
}
@@ -20,9 +20,7 @@ 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)]
@@ -98,13 +96,11 @@ public sealed class GameCreationWizardStepTransitionsTests
}
[Fact]
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
public async Task StaleCapacityWaitlistCallback_WithoutCapacity_StaysOnCurrentStep()
{
// 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.
// 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);
@@ -112,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
{
@@ -138,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]
@@ -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")]
@@ -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.3", 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>&lt;Version&gt;...&lt;/Version&gt;</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;
}
}