feat(web): add private club showcases with membership flow (v3.7.0)
PR Checks / test-and-build (pull_request) Successful in 7m28s
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:
@@ -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}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user