feat(web): add private club showcases with membership flow (v3.7.0)
PR Checks / test-and-build (pull_request) Successful in 7m28s

Implements Issue #110: game masters can now publish sessions
exclusively to a club's private showcase, gated behind a
member application and approval flow. Adds a 4-state
publication_mode (None/Catalog/ClubOnly/Both) replacing the
binary is_public, plus a club_memberships table with
Pending/Active/Rejected/Left lifecycle and partial unique
index ensuring a single Active row per (group, player).

Highlights
- V030 migration: club_memberships, publication_mode, drop
  is_public, recreate partial indexes, portfolio_games gains
  publication_mode.
- PublicationMode enum + extensions in GmRelay.Shared.
- ISessionStore gains 12 membership/showcase methods;
  AuthorizedMembershipService owns the membership flow with
  GM-only approve/reject authorization.
- PublicClub / PublicMasterProfile / PublicSession: member-
  aware queries (ClubOnly visible only to Active members).
- New pages: MyClubMemberships (/profile/memberships) and
  ClubApplications (/group/{id}/applications).
- GroupDetails and EditSession switch from a bool toggle to
  a 4-state publication_mode selector.
- NavMenu adds Moji kluby, PublicLayout adds Kluby.

Tests: 4 new test files (PublicationMode, ClubMemberships,
AuthorizedMembershipService, ClubShowcaseSource) + updates
to PublicClubPages, AuthorizedSessionService/Portfolio
service FakeSessionStore, CampaignTemplatesNavigation.
493 tests pass.

Bump version 3.6.0 -> 3.7.0 across Directory.Build.props,
compose.yaml, deploy.yml, NavMenu.razor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 11:09:22 +03:00
parent 992f71c0e4
commit 6cb2fbe610
27 changed files with 1602 additions and 109 deletions
@@ -0,0 +1,62 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class AuthorizedMembershipServiceTests
{
[Fact]
public async Task AuthorizedMembershipService_ShouldRequireAuthenticationForApply()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("ApplyForCurrentUserAsync", service, StringComparison.Ordinal);
Assert.Contains("User is not authenticated", service, StringComparison.Ordinal);
Assert.Contains("GetPlayerIdByPlatformIdentityAsync", service, StringComparison.Ordinal);
}
[Fact]
public async Task AuthorizedMembershipService_ShouldValidateMessageLength()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("1000", service, StringComparison.Ordinal);
Assert.Contains("н", service, StringComparison.Ordinal); // Russian message length
}
[Fact]
public async Task AuthorizedMembershipService_ShouldRestrictGmActionsToGroupManagers()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("ApproveForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("RejectForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("ResolveMembershipContextForGmAsync", service, StringComparison.Ordinal);
Assert.Contains("GetGroupIdForMembershipAsync", service, StringComparison.Ordinal);
}
[Fact]
public async Task AuthorizedMembershipService_ShouldExposePendingApplications()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("GetPendingApplicationsAsync", service, StringComparison.Ordinal);
Assert.Contains("GetPendingApplicationsCountForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("GetMineAsync", service, StringComparison.Ordinal);
Assert.Contains("LeaveClubForCurrentUserAsync", service, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -794,10 +794,21 @@ public sealed class AuthorizedPortfolioServiceTests
public Task<WebGameGroup?> GetGroupAsync(Guid groupId) => throw new NotImplementedException();
public Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId) => throw new NotImplementedException();
public Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled) => throw new NotImplementedException();
public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic) => throw new NotImplementedException();
public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic) => throw new NotImplementedException();
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug) => throw new NotImplementedException();
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId) => throw new NotImplementedException();
public Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode) => throw new NotImplementedException();
public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode) => throw new NotImplementedException();
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException();
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) => throw new NotImplementedException();
public Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId) => throw new NotImplementedException();
public Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) => throw new NotImplementedException();
public Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) => throw new NotImplementedException();
public Task<int> GetPendingApplicationsCountAsync(Guid groupId) => throw new NotImplementedException();
public Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId) => throw new NotImplementedException();
public Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId) => throw new NotImplementedException();
public Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message) => throw new NotImplementedException();
public Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId) => throw new NotImplementedException();
public Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId) => throw new NotImplementedException();
public Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId) => throw new NotImplementedException();
public Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId) => throw new NotImplementedException();
public Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) => throw new NotImplementedException();
public Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId) => throw new NotImplementedException();
public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) => throw new NotImplementedException();
@@ -824,7 +835,7 @@ public sealed class AuthorizedPortfolioServiceTests
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) => throw new NotImplementedException();
public Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId) => throw new NotImplementedException();
public Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio) => throw new NotImplementedException();
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug) => throw new NotImplementedException();
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException();
public Task<List<LinkedIdentity>> GetLinkedIdentitiesAsync(string platform, string externalUserId) => throw new NotImplementedException();
public Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName) => throw new NotImplementedException();
public Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId) => throw new NotImplementedException();
@@ -839,9 +839,11 @@ public sealed class AuthorizedSessionServiceTests
public Guid? LastPublicSessionId { get; private set; }
public Guid? LastPublicSessionGroupId { get; private set; }
public bool? LastSessionPublicValue { get; private set; }
public PublicationMode? LastSessionPublicationMode { get; private set; }
public Guid? LastPublicBatchId { get; private set; }
public Guid? LastPublicBatchGroupId { get; private set; }
public bool? LastBatchPublicValue { get; private set; }
public PublicationMode? LastBatchPublicationMode { get; private set; }
public bool RemovePlayerCalled { get; private set; }
public Guid? LastRemovedPlayerSessionId { get; private set; }
public Guid? LastRemovedPlayerGroupId { get; private set; }
@@ -888,45 +890,81 @@ public sealed class AuthorizedSessionServiceTests
return Task.CompletedTask;
}
public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
public Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode)
{
SetSessionPublicCalled = true;
LastPublicSessionId = sessionId;
LastPublicSessionGroupId = groupId;
LastSessionPublicValue = isPublic;
LastSessionPublicationMode = mode;
if (sessionsById.TryGetValue(sessionId, out var session))
{
sessionsById[sessionId] = session with { IsPublic = isPublic };
sessionsById[sessionId] = session with { PublicationMode = mode.ToDatabaseValue() };
}
return Task.CompletedTask;
}
public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode)
{
SetBatchPublicCalled = true;
LastPublicBatchId = batchId;
LastPublicBatchGroupId = groupId;
LastBatchPublicValue = isPublic;
LastBatchPublicationMode = mode;
foreach (var session in sessionsById.Values.Where(session => session.BatchId == batchId && session.GroupId == groupId).ToList())
{
sessionsById[session.Id] = session with { IsPublic = isPublic };
sessionsById[session.Id] = session with { PublicationMode = mode.ToDatabaseValue() };
}
return Task.CompletedTask;
}
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug) =>
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) =>
Task.FromResult<WebPublicClub?>(null);
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId) =>
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) =>
Task.FromResult<WebPublicSession?>(null);
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsManager(groupId, telegramId));
public Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId) =>
Task.FromResult(false);
public Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) =>
Task.FromResult<Guid?>(null);
public Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) =>
Task.FromResult<IReadOnlyList<WebClubShowcaseSession>>([]);
public Task<int> GetPendingApplicationsCountAsync(Guid groupId) =>
Task.FromResult(0);
public Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId) =>
Task.FromResult(new List<WebPendingApplication>());
public Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId) =>
Task.FromResult(new List<WebMembership>());
public Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message) =>
throw new NotImplementedException();
public Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId) =>
throw new NotImplementedException();
public Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId) =>
throw new NotImplementedException();
public Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId) =>
throw new NotImplementedException();
public Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId) =>
Task.FromResult<Guid?>(null);
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId = null) =>
Task.FromResult<PublicMasterProfile?>(null);
public Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsOwner(groupId, telegramId));
@@ -15,7 +15,7 @@ public sealed class CampaignTemplatesNavigationTests
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
{
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
Assert.Contains("v3.6.0", navMenu, StringComparison.Ordinal);
Assert.Contains("v3.7.0", navMenu, StringComparison.Ordinal);
}
[Fact]
@@ -0,0 +1,94 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class ClubMembershipsTests
{
[Fact]
public async Task SessionStore_ShouldExposeMembershipMethods()
{
var sessionStore = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/ISessionStore.cs");
Assert.Contains("ApplyForMembershipAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("ApproveMembershipAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("RejectMembershipAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("LeaveClubMembershipAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetPendingApplicationsAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetMembershipsForPlayerAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("IsActiveClubMemberAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetGroupIdForMembershipAsync", sessionStore, StringComparison.Ordinal);
}
[Fact]
public async Task SessionService_ShouldFilterPublicSessionsWithMemberAwareClause()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
// Member-aware: ClubOnly only visible to Active members
Assert.Contains("publication_mode = 'ClubOnly'", service, StringComparison.Ordinal);
Assert.Contains("club_memberships", service, StringComparison.Ordinal);
Assert.Contains("cm.status = 'Active'", service, StringComparison.Ordinal);
}
[Fact]
public async Task AuthorizedMembershipService_ShouldValidateCallerForGmActions()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("IsGroupManagerAsync", service, StringComparison.Ordinal);
Assert.Contains("ApproveForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("RejectForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("SessionAccessDeniedException", service, StringComparison.Ordinal);
}
[Fact]
public async Task MyClubMembershipsPage_ShouldRenderLeaveAndCancelButtons()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/MyClubMemberships.razor");
Assert.Contains("@page \"/profile/memberships\"", page, StringComparison.Ordinal);
Assert.Contains("[Authorize]", page, StringComparison.Ordinal);
Assert.Contains("Покинуть клуб", page, StringComparison.Ordinal);
Assert.Contains("Отозвать заявку", page, StringComparison.Ordinal);
Assert.Contains("Active", page, StringComparison.Ordinal);
Assert.Contains("Pending", page, StringComparison.Ordinal);
}
[Fact]
public async Task ClubApplicationsPage_ShouldRenderApproveAndReject()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/ClubApplications.razor");
Assert.Contains("/applications", page, StringComparison.Ordinal);
Assert.Contains("[Authorize]", page, StringComparison.Ordinal);
Assert.Contains("Одобрить", page, StringComparison.Ordinal);
Assert.Contains("Отклонить", page, StringComparison.Ordinal);
}
[Fact]
public async Task PublicClubPage_ShouldExposeApplicationCtaAndMembersOnlyBlock()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
Assert.Contains("viewerPlayerId", page, StringComparison.Ordinal);
Assert.Contains("Подать заявку", page, StringComparison.Ordinal);
Assert.Contains("Войти как участник", page, StringComparison.Ordinal);
Assert.Contains("Игры для участников клуба", page, StringComparison.Ordinal);
Assert.Contains("ApplyForCurrentUserAsync", page, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -0,0 +1,104 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class ClubShowcaseSourceTests
{
[Fact]
public async Task PublicClubPage_ShouldRenderMembersOnlyBlock()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
Assert.Contains("Игры для участников клуба", page, StringComparison.Ordinal);
Assert.Contains("viewerIsActiveMember", page, StringComparison.Ordinal);
Assert.Contains("members-only-section", page, StringComparison.Ordinal);
}
[Fact]
public async Task PublicClubPage_ShouldRenderApplyAndLoginCtas()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
Assert.Contains("Подать заявку", page, StringComparison.Ordinal);
Assert.Contains("Войти как участник", page, StringComparison.Ordinal);
Assert.Contains("applicationMessage", page, StringComparison.Ordinal);
Assert.Contains("ApplyForCurrentUserAsync", page, StringComparison.Ordinal);
}
[Fact]
public async Task PublicClubPage_ShouldHideMembersOnlyBlockForAnonymous()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
// Anonymous users must not see the members-only block content
Assert.Contains("viewerIsActiveMember", page, StringComparison.Ordinal);
// Login CTA appears when viewerPlayerId is null
Assert.Contains("viewerPlayerId is null", page, StringComparison.Ordinal);
}
[Fact]
public async Task PublicLayout_ShouldExposeClubsLink()
{
var layout = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Layout/PublicLayout.razor");
Assert.Contains("href=\"/showcase\"", layout, StringComparison.Ordinal);
Assert.Contains("Клубы", layout, StringComparison.Ordinal);
}
[Fact]
public async Task NavMenu_ShouldExposeMyClubsLink()
{
var menu = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Layout/NavMenu.razor");
Assert.Contains("href=\"profile/memberships\"", menu, StringComparison.Ordinal);
Assert.Contains("Мои клубы", menu, StringComparison.Ordinal);
}
[Fact]
public async Task GroupDetails_ShouldExposeApplicationsLink()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
Assert.Contains("/applications", page, StringComparison.Ordinal);
Assert.Contains("Заявки участников", page, StringComparison.Ordinal);
Assert.Contains("pendingApplicationsCount", page, StringComparison.Ordinal);
}
[Fact]
public async Task GroupDetails_ShouldUsePublicationModeSelectorNotBooleanToggle()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
Assert.DoesNotContain("SetSessionPublic(session.Id, !session.IsPublic)", page, StringComparison.Ordinal);
Assert.DoesNotContain("SetBatchPublic(batch, !batch.AllSessionsPublic)", page, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicationMode", page, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicationMode", page, StringComparison.Ordinal);
}
[Fact]
public async Task EditSession_ShouldExposePublicationModeSelector()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/EditSession.razor");
Assert.Contains("PublicationMode", page, StringComparison.Ordinal);
Assert.Contains("Режим публикации", page, StringComparison.Ordinal);
Assert.Contains("Catalog", page, StringComparison.Ordinal);
Assert.Contains("ClubOnly", page, StringComparison.Ordinal);
Assert.Contains("Both", page, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -14,6 +14,20 @@ public sealed class PublicClubPagesTests
Assert.Contains("ix_sessions_public_schedule", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV030_ShouldAddClubMembershipsAndPublicationMode()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("CREATE TABLE club_memberships", migration, StringComparison.Ordinal);
Assert.Contains("status", migration, StringComparison.Ordinal);
Assert.Contains("role", migration, StringComparison.Ordinal);
Assert.Contains("ux_club_memberships_one_active", migration, StringComparison.Ordinal);
Assert.Contains("publication_mode", migration, StringComparison.Ordinal);
Assert.Contains("DROP COLUMN is_public", migration, StringComparison.Ordinal);
Assert.Contains("portfolio_games", migration, StringComparison.Ordinal);
}
[Fact]
public async Task PublicPages_ShouldExposeReadOnlyRoutesWithoutPrivateSessionData()
{
@@ -40,10 +54,10 @@ public sealed class PublicClubPagesTests
Assert.Contains("GetPublicClubBySlugAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetPublicSessionAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicationModeAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicationModeAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("g.public_schedule_enabled = true", service, StringComparison.Ordinal);
Assert.Contains("s.is_public = true", service, StringComparison.Ordinal);
Assert.Contains("s.publication_mode IN ('Catalog', 'Both')", service, StringComparison.Ordinal);
Assert.Contains("s.status <> @Cancelled", service, StringComparison.Ordinal);
Assert.DoesNotContain("p.display_name AS DisplayName,\r\n p.external_username", PublicQuerySection(service), StringComparison.Ordinal);
}
@@ -55,8 +69,8 @@ public sealed class PublicClubPagesTests
var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs");
Assert.Contains("UpdatePublicGroupSettingsForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicationModeForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicationModeForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("PublicSessionUrl", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("NormalizePublicSlug", authorizedService, StringComparison.Ordinal);
Assert.Contains("IsGroupManagerAsync", authorizedService, StringComparison.Ordinal);
@@ -0,0 +1,79 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class PublicationModeTests
{
[Fact]
public void PublicationMode_ShouldHaveFourValues()
{
var values = Enum.GetValues<GmRelay.Shared.Domain.PublicationMode>();
Assert.Equal(4, values.Length);
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.None, values);
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.Catalog, values);
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.ClubOnly, values);
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.Both, values);
}
[Fact]
public async Task MigrationV030_ShouldAddClubMembershipsTable()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("CREATE TABLE club_memberships", migration, StringComparison.Ordinal);
Assert.Contains("status", migration, StringComparison.Ordinal);
Assert.Contains("role", migration, StringComparison.Ordinal);
Assert.Contains("Pending", migration, StringComparison.Ordinal);
Assert.Contains("Active", migration, StringComparison.Ordinal);
Assert.Contains("Rejected", migration, StringComparison.Ordinal);
Assert.Contains("Left", migration, StringComparison.Ordinal);
Assert.Contains("ux_club_memberships_one_active", migration, StringComparison.Ordinal);
Assert.Contains("Member", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV030_ShouldReplaceIsPublicWithPublicationModeEnum()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("ADD COLUMN publication_mode", migration, StringComparison.Ordinal);
Assert.Contains("ck_sessions_publication_mode", migration, StringComparison.Ordinal);
Assert.Contains("'None', 'Catalog', 'ClubOnly', 'Both'", migration, StringComparison.Ordinal);
Assert.Contains("UPDATE sessions SET publication_mode = 'Both' WHERE is_public = true", migration, StringComparison.Ordinal);
Assert.Contains("UPDATE sessions SET publication_mode = 'None' WHERE is_public = false", migration, StringComparison.Ordinal);
Assert.Contains("DROP COLUMN is_public", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV030_ShouldRecreatePartialIndexUsingPublicationMode()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("ix_sessions_public_schedule", migration, StringComparison.Ordinal);
Assert.Contains("publication_mode IN ('Catalog', 'Both')", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV030_ShouldAddPortfolioPublicationModeColumn()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("portfolio_games", migration, StringComparison.Ordinal);
Assert.Contains("ix_portfolio_games_showcase", migration, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}