Files
GmRelayBot/tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs
T
Toutsu 6cb2fbe610
PR Checks / test-and-build (pull_request) Successful in 7m28s
feat(web): add private club showcases with membership flow (v3.7.0)
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>
2026-06-03 11:09:22 +03:00

869 lines
40 KiB
C#

using GmRelay.Shared.Domain;
using GmRelay.Web.Services;
using GmRelay.Web.Services.Portfolio;
using GmRelay.Web.Services.Portfolio.Covers;
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
namespace GmRelay.Bot.Tests.Web;
public sealed class AuthorizedPortfolioServiceTests
{
private static IHttpContextAccessor CreateAccessor(string externalUserId, string? name = null)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, externalUserId),
new Claim("TelegramId", externalUserId),
new Claim("Platform", "Telegram")
};
if (name is not null)
claims.Add(new Claim(ClaimTypes.Name, name));
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var httpContext = new DefaultHttpContext { User = principal };
return new HttpContextAccessor { HttpContext = httpContext };
}
private static IHttpContextAccessor CreateAnonymousAccessor()
{
var httpContext = new DefaultHttpContext();
return new HttpContextAccessor { HttpContext = httpContext };
}
private static AuthorizedPortfolioService CreateService(
FakePortfolioStore? portfolioStore = null,
FakeSessionStore? sessionStore = null,
FakePortfolioCoverStorage? coverStorage = null,
IHttpContextAccessor? accessor = null,
bool isManager = true,
Guid? knownGroupId = null)
{
portfolioStore ??= new FakePortfolioStore();
sessionStore ??= new FakeSessionStore();
coverStorage ??= new FakePortfolioCoverStorage();
accessor ??= CreateAccessor("1001");
// Wire a known group + manager relationship for the test
if (knownGroupId is not null)
{
portfolioStore.GroupIds[Guid.NewGuid()] = knownGroupId.Value; // placeholder
sessionStore.ManagerFlags[(knownGroupId.Value, "Telegram", "1001")] = isManager;
}
return new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
}
[Fact]
public async Task CreateDraftForCurrentUserAsync_ShouldAllowCoGm()
{
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var draftId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore
{
CreateDraftResult = draftId,
PortfolioGameGroupIds = new Dictionary<Guid, Guid?>
{
[draftId] = groupId
}
};
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = true
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
var created = await service.CreateDraftForCurrentUserAsync(groupId, sessionId);
Assert.Equal(draftId, created);
Assert.Equal(groupId, portfolioStore.LastCreateGroupId);
Assert.Equal(sessionId, portfolioStore.LastCreatePreselectedSessionId);
}
[Fact]
public async Task CreateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager()
{
var groupId = Guid.NewGuid();
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = false
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(new FakePortfolioStore(), sessionStore, new FakePortfolioCoverStorage(), accessor);
await Assert.ThrowsAsync<SessionAccessDeniedException>(
() => service.CreateDraftForCurrentUserAsync(groupId, null));
}
[Fact]
public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteOldCoverAfterSuccessfulSwap()
{
var groupId = Guid.NewGuid();
var portfolioGameId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore
{
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
CoverPriorKey = "old.png"
};
var coverStorage = new FakePortfolioCoverStorage
{
SaveKey = "new.png"
};
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = true
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
await service.ReplaceCoverForCurrentUserAsync(portfolioGameId, new MemoryStream([0x89, 0x50]), "image/png");
Assert.Contains("old.png", coverStorage.DeletedKeys);
Assert.Contains("new.png", coverStorage.SavedKeys);
Assert.Equal(portfolioGameId, portfolioStore.LastSetCoverGameId);
Assert.Equal("new.png", portfolioStore.LastSetCoverKey);
}
[Fact]
public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteNewCoverWhenPersistenceFails()
{
var groupId = Guid.NewGuid();
var portfolioGameId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore
{
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
SetCoverThrows = new InvalidOperationException("boom")
};
var coverStorage = new FakePortfolioCoverStorage
{
SaveKey = "new.png"
};
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = true
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
await Assert.ThrowsAsync<InvalidOperationException>(
() => service.ReplaceCoverForCurrentUserAsync(portfolioGameId, new MemoryStream([0x89, 0x50]), "image/png"));
Assert.Contains("new.png", coverStorage.DeletedKeys);
Assert.DoesNotContain("old.png", coverStorage.DeletedKeys);
}
[Fact]
public async Task GetPortfolioGameForCurrentUserAsync_ShouldReturnNullForAnotherClubManager()
{
var groupId = Guid.NewGuid();
var portfolioGameId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore
{
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
};
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = false
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
var editor = await service.GetPortfolioGameForCurrentUserAsync(portfolioGameId);
Assert.Null(editor);
}
[Fact]
public async Task GetPortfolioGameForCurrentUserAsync_ShouldReturnEditorForCoGm()
{
var groupId = Guid.NewGuid();
var portfolioGameId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore
{
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
EditorResult = new PortfolioGameEditor(
portfolioGameId,
groupId,
"Title",
"slug",
"Description",
"/portfolio-covers/x.png",
"D&D 5e",
"Online",
DateTime.UtcNow,
false,
[],
[],
[])
};
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = true
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
var editor = await service.GetPortfolioGameForCurrentUserAsync(portfolioGameId);
Assert.NotNull(editor);
Assert.Equal("Title", editor!.Title);
}
[Fact]
public async Task UpdateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager()
{
var groupId = Guid.NewGuid();
var portfolioGameId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore
{
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
};
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = false
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
var update = new PortfolioGameUpdate("Updated", "updated-slug", "desc", null, "Online", [], []);
await Assert.ThrowsAsync<SessionAccessDeniedException>(
() => service.UpdateDraftForCurrentUserAsync(portfolioGameId, update));
Assert.False(portfolioStore.UpdateCalled);
}
[Fact]
public async Task UpdateDraftForCurrentUserAsync_ShouldNormalizeFieldsBeforeStoring()
{
var groupId = Guid.NewGuid();
var portfolioGameId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore
{
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
};
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = true
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
var update = new PortfolioGameUpdate(" Updated ", " my-slug ", " description ", null, " Online ", [], []);
await service.UpdateDraftForCurrentUserAsync(portfolioGameId, update);
Assert.True(portfolioStore.UpdateCalled);
Assert.Equal("Updated", portfolioStore.LastUpdateTitle);
Assert.Equal("my-slug", portfolioStore.LastUpdateSlug);
Assert.Equal("description", portfolioStore.LastUpdateDescription);
Assert.Equal("Online", portfolioStore.LastUpdateFormat);
}
[Fact]
public async Task ModerateReviewForCurrentUserAsync_ShouldResolveEffectivePlayerAndForwardIt()
{
var groupId = Guid.NewGuid();
var portfolioGameId = Guid.NewGuid();
var reviewId = Guid.NewGuid();
var effectivePlayerId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore
{
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
};
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = true
},
EffectivePlayerId = effectivePlayerId
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
await service.ModerateReviewForCurrentUserAsync(portfolioGameId, reviewId, "Approved");
Assert.True(portfolioStore.ModerateCalled);
Assert.Equal(reviewId, portfolioStore.LastModerateReviewId);
Assert.Equal(portfolioGameId, portfolioStore.LastModerateGameId);
Assert.Equal(groupId, portfolioStore.LastModerateGroupId);
Assert.Equal(effectivePlayerId, portfolioStore.LastModeratePlayerId);
Assert.Equal("Approved", portfolioStore.LastModerateStatus);
}
[Fact]
public async Task ModerateReviewForCurrentUserAsync_ShouldRejectAnotherClubManager()
{
var groupId = Guid.NewGuid();
var portfolioGameId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore
{
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
};
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = false
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
await Assert.ThrowsAsync<SessionAccessDeniedException>(
() => service.ModerateReviewForCurrentUserAsync(portfolioGameId, Guid.NewGuid(), "Approved"));
Assert.False(portfolioStore.ModerateCalled);
}
[Fact]
public async Task DeleteForCurrentUserAsync_ShouldDeleteCoverAfterRowDeletion()
{
var groupId = Guid.NewGuid();
var portfolioGameId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore
{
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
DeleteCoverKey = "old.png"
};
var coverStorage = new FakePortfolioCoverStorage();
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = true
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
await service.DeleteForCurrentUserAsync(portfolioGameId);
Assert.True(portfolioStore.DeleteCalled);
Assert.Equal(portfolioGameId, portfolioStore.LastDeleteGameId);
Assert.Equal(groupId, portfolioStore.LastDeleteGroupId);
Assert.Contains("old.png", coverStorage.DeletedKeys);
}
[Fact]
public async Task DeleteForCurrentUserAsync_ShouldStillDeleteRowWhenNoCover()
{
var groupId = Guid.NewGuid();
var portfolioGameId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore
{
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId },
DeleteCoverKey = null
};
var coverStorage = new FakePortfolioCoverStorage();
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = true
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, coverStorage, accessor);
await service.DeleteForCurrentUserAsync(portfolioGameId);
Assert.True(portfolioStore.DeleteCalled);
Assert.Empty(coverStorage.DeletedKeys);
}
[Fact]
public async Task SetPublicationForCurrentUserAsync_ShouldForwardIsPublicFlag()
{
var groupId = Guid.NewGuid();
var portfolioGameId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore
{
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = groupId }
};
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = true
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
await service.SetPublicationForCurrentUserAsync(portfolioGameId, isPublic: true);
Assert.True(portfolioStore.PublicationCalled);
Assert.Equal(portfolioGameId, portfolioStore.LastPublicationGameId);
Assert.Equal(groupId, portfolioStore.LastPublicationGroupId);
Assert.True(portfolioStore.LastPublicationIsPublic);
}
[Fact]
public async Task GetReviewSubmissionStateForCurrentUserAsync_ShouldReturnRequiresAuthForAnonymous()
{
var portfolioStore = new FakePortfolioStore();
var sessionStore = new FakeSessionStore();
var accessor = CreateAnonymousAccessor();
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
var state = await service.GetReviewSubmissionStateForCurrentUserAsync("some-slug");
Assert.Equal(PortfolioReviewSubmissionState.RequiresAuthentication, state);
}
[Fact]
public async Task GetReviewSubmissionStateForCurrentUserAsync_ShouldForwardPlatformAndUserId()
{
var portfolioStore = new FakePortfolioStore
{
ReviewStateResult = PortfolioReviewSubmissionState.Eligible
};
var sessionStore = new FakeSessionStore();
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
var state = await service.GetReviewSubmissionStateForCurrentUserAsync("some-slug");
Assert.Equal(PortfolioReviewSubmissionState.Eligible, state);
Assert.Equal("some-slug", portfolioStore.LastReviewStateSlug);
Assert.Equal("Telegram", portfolioStore.LastReviewStatePlatform);
Assert.Equal("1001", portfolioStore.LastReviewStateExternalUserId);
}
[Fact]
public async Task SubmitReviewForCurrentUserAsync_ShouldRejectAnonymous()
{
var portfolioStore = new FakePortfolioStore();
var sessionStore = new FakeSessionStore();
var accessor = CreateAnonymousAccessor();
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
await Assert.ThrowsAsync<SessionAccessDeniedException>(
() => service.SubmitReviewForCurrentUserAsync("slug", "great adventure, would play again", true));
Assert.False(portfolioStore.SubmitReviewCalled);
}
[Fact]
public async Task SubmitReviewForCurrentUserAsync_ShouldRejectMissingConsent()
{
var portfolioStore = new FakePortfolioStore();
var sessionStore = new FakeSessionStore();
var accessor = CreateAccessor("1001", "Alice");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
await Assert.ThrowsAsync<InvalidOperationException>(
() => service.SubmitReviewForCurrentUserAsync("slug", "great adventure, would play again", false));
Assert.False(portfolioStore.SubmitReviewCalled);
}
[Fact]
public async Task SubmitReviewForCurrentUserAsync_ShouldNormalizeBodyAndForwardIdentity()
{
var portfolioStore = new FakePortfolioStore();
var sessionStore = new FakeSessionStore();
var accessor = CreateAccessor("1001", "Alice");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
await service.SubmitReviewForCurrentUserAsync(
" the-curse-of-strahd ",
" great adventure, would play again ",
true);
Assert.True(portfolioStore.SubmitReviewCalled);
Assert.Equal("the-curse-of-strahd", portfolioStore.LastSubmitSlug);
Assert.Equal("great adventure, would play again", portfolioStore.LastSubmitBody);
Assert.Equal("Alice", portfolioStore.LastSubmitDisplayName);
Assert.Equal("Telegram", portfolioStore.LastSubmitPlatform);
Assert.Equal("1001", portfolioStore.LastSubmitExternalUserId);
}
[Fact]
public async Task GetCompletedSessionsForCurrentUserAsync_ShouldReturnEmptyForNonManager()
{
var groupId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore();
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = false
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
var sessions = await service.GetCompletedSessionsForCurrentUserAsync(groupId);
Assert.Empty(sessions);
Assert.Null(portfolioStore.LastEligibleGroupId);
}
[Fact]
public async Task GetCompletedSessionsForCurrentUserAsync_ShouldReturnSessionsForManager()
{
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore
{
EligibleSessions =
[
new PortfolioSessionOption(sessionId, "Old session", DateTime.UtcNow.AddDays(-7), false)
]
};
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = true
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
var sessions = await service.GetCompletedSessionsForCurrentUserAsync(groupId);
Assert.Single(sessions);
Assert.Equal(sessionId, sessions[0].Id);
}
[Fact]
public async Task GetPortfolioGamesForCurrentUserAsync_ShouldReturnEmptyForNonManager()
{
var groupId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore();
var sessionStore = new FakeSessionStore
{
ManagerFlags = new Dictionary<(Guid, string, string), bool>
{
[(groupId, "Telegram", "1001")] = false
}
};
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
var games = await service.GetPortfolioGamesForCurrentUserAsync(groupId);
Assert.Empty(games);
}
[Fact]
public async Task IDScopedMethod_ShouldThrowWhenPortfolioGameDoesNotExist()
{
var portfolioGameId = Guid.NewGuid();
var portfolioStore = new FakePortfolioStore
{
PortfolioGameGroupIds = new Dictionary<Guid, Guid?> { [portfolioGameId] = null }
};
var sessionStore = new FakeSessionStore();
var accessor = CreateAccessor("1001");
var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor);
await Assert.ThrowsAsync<InvalidOperationException>(
() => service.DeleteForCurrentUserAsync(portfolioGameId));
}
// --- Fakes ---
private sealed class FakePortfolioStore : IPortfolioStore
{
public Dictionary<Guid, Guid?> PortfolioGameGroupIds { get; set; } = new();
public Dictionary<Guid, Guid> GroupIds { get; set; } = new();
public Guid CreateDraftResult { get; set; } = Guid.NewGuid();
public Guid? LastCreateGroupId { get; private set; }
public Guid? LastCreatePreselectedSessionId { get; private set; }
public bool CreateCalled { get; private set; }
public PortfolioGameEditor? EditorResult { get; set; }
public string? CoverPriorKey { get; set; }
public Exception? SetCoverThrows { get; set; }
public Guid? LastSetCoverGameId { get; private set; }
public Guid? LastSetCoverGroupId { get; private set; }
public string? LastSetCoverKey { get; private set; }
public bool UpdateCalled { get; private set; }
public Guid? LastUpdateGameId { get; private set; }
public Guid? LastUpdateGroupId { get; private set; }
public string? LastUpdateTitle { get; private set; }
public string? LastUpdateSlug { get; private set; }
public string? LastUpdateDescription { get; private set; }
public string? LastUpdateFormat { get; private set; }
public bool DeleteCalled { get; private set; }
public Guid? LastDeleteGameId { get; private set; }
public Guid? LastDeleteGroupId { get; private set; }
public string? DeleteCoverKey { get; set; }
public bool PublicationCalled { get; private set; }
public Guid? LastPublicationGameId { get; private set; }
public Guid? LastPublicationGroupId { get; private set; }
public bool? LastPublicationIsPublic { get; private set; }
public bool ModerateCalled { get; private set; }
public Guid? LastModerateReviewId { get; private set; }
public Guid? LastModerateGameId { get; private set; }
public Guid? LastModerateGroupId { get; private set; }
public Guid? LastModeratePlayerId { get; private set; }
public string? LastModerateStatus { get; private set; }
public IReadOnlyList<PortfolioSessionOption> EligibleSessions { get; set; } = [];
public Guid? LastEligibleGroupId { get; private set; }
public IReadOnlyList<PortfolioGameSummary> GamesForGroup { get; set; } = [];
public PortfolioReviewSubmissionState ReviewStateResult { get; set; } = PortfolioReviewSubmissionState.Ineligible;
public string? LastReviewStateSlug { get; private set; }
public string? LastReviewStatePlatform { get; private set; }
public string? LastReviewStateExternalUserId { get; private set; }
public bool SubmitReviewCalled { get; private set; }
public string? LastSubmitSlug { get; private set; }
public string? LastSubmitBody { get; private set; }
public string? LastSubmitDisplayName { get; private set; }
public string? LastSubmitPlatform { get; private set; }
public string? LastSubmitExternalUserId { get; private set; }
public Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug) =>
Task.FromResult<IReadOnlyList<PublicPortfolioCard>>([]);
public Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug) =>
Task.FromResult<IReadOnlyList<PublicPortfolioCard>>([]);
public Task<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug) =>
Task.FromResult<PublicPortfolioGame?>(null);
public Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId)
{
LastEligibleGroupId = groupId;
return Task.FromResult(GamesForGroup);
}
public Task<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId)
{
PortfolioGameGroupIds.TryGetValue(portfolioGameId, out var groupId);
return Task.FromResult(groupId);
}
public Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId) =>
Task.FromResult(EditorResult);
public Task<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId)
{
LastEligibleGroupId = groupId;
return Task.FromResult(EligibleSessions);
}
public Task<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId) =>
Task.FromResult<IReadOnlyList<PortfolioMasterOption>>([]);
public Task<Guid> CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId)
{
CreateCalled = true;
LastCreateGroupId = groupId;
LastCreatePreselectedSessionId = preselectedSessionId;
return Task.FromResult(CreateDraftResult);
}
public Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update)
{
UpdateCalled = true;
LastUpdateGameId = portfolioGameId;
LastUpdateGroupId = groupId;
LastUpdateTitle = update.Title;
LastUpdateSlug = update.PublicSlug;
LastUpdateDescription = update.Description;
LastUpdateFormat = update.Format;
return Task.CompletedTask;
}
public Task<string?> SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey)
{
LastSetCoverGameId = portfolioGameId;
LastSetCoverGroupId = groupId;
LastSetCoverKey = storageKey;
if (SetCoverThrows is not null)
{
throw SetCoverThrows;
}
return Task.FromResult(CoverPriorKey);
}
public Task<string?> DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId)
{
DeleteCalled = true;
LastDeleteGameId = portfolioGameId;
LastDeleteGroupId = groupId;
return Task.FromResult(DeleteCoverKey);
}
public Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic)
{
PublicationCalled = true;
LastPublicationGameId = portfolioGameId;
LastPublicationGroupId = groupId;
LastPublicationIsPublic = isPublic;
return Task.CompletedTask;
}
public Task ModeratePortfolioReviewAsync(
Guid reviewId,
Guid portfolioGameId,
Guid groupId,
Guid moderatorPlayerId,
string moderationStatus)
{
ModerateCalled = true;
LastModerateReviewId = reviewId;
LastModerateGameId = portfolioGameId;
LastModerateGroupId = groupId;
LastModeratePlayerId = moderatorPlayerId;
LastModerateStatus = moderationStatus;
return Task.CompletedTask;
}
public Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId)
{
LastReviewStateSlug = slug;
LastReviewStatePlatform = platform;
LastReviewStateExternalUserId = externalUserId;
return Task.FromResult(ReviewStateResult);
}
public Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body)
{
SubmitReviewCalled = true;
LastSubmitSlug = slug;
LastSubmitPlatform = platform;
LastSubmitExternalUserId = externalUserId;
LastSubmitDisplayName = displayName;
LastSubmitBody = body;
return Task.CompletedTask;
}
}
private sealed class FakeSessionStore : ISessionStore
{
public Dictionary<(Guid GroupId, string Platform, string ExternalUserId), bool> ManagerFlags { get; set; } = new();
public Guid? EffectivePlayerId { get; set; } = Guid.NewGuid();
public Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
{
if (ManagerFlags.TryGetValue((groupId, platform, externalUserId), out var flag))
{
return Task.FromResult(flag);
}
return Task.FromResult(false);
}
public Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId) =>
Task.FromResult(EffectivePlayerId);
// Unused interface members — throw so accidental use surfaces in test output
public Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId) => throw new NotImplementedException();
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 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();
public Task<WebSession?> GetSessionAsync(Guid sessionId) => throw new NotImplementedException();
public Task<WebSessionBatch?> GetBatchAsync(Guid batchId) => throw new NotImplementedException();
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) => throw new NotImplementedException();
public Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId) => throw new NotImplementedException();
public Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink) => throw new NotImplementedException();
public Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode) => throw new NotImplementedException();
public Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays) => throw new NotImplementedException();
public Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval) => throw new NotImplementedException();
public Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId) => throw new NotImplementedException();
public Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId) => throw new NotImplementedException();
public Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request) => throw new NotImplementedException();
public Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId) => throw new NotImplementedException();
public Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt) => throw new NotImplementedException();
public Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername) => throw new NotImplementedException();
public Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId) => throw new NotImplementedException();
public Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId) => throw new NotImplementedException();
public Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId) => throw new NotImplementedException();
public Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId) => throw new NotImplementedException();
public Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue) => throw new NotImplementedException();
public Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) => throw new NotImplementedException();
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, 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();
public Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl) => throw new NotImplementedException();
public Task<IReadOnlyList<GmRelay.Shared.Features.Showcase.ShowcaseSessionDto>> GetShowcaseSessionsAsync(GmRelay.Shared.Features.Showcase.ShowcaseFilter filter, int page, int pageSize) => throw new NotImplementedException();
public Task<GmRelay.Shared.Features.Showcase.ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId) => throw new NotImplementedException();
public Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) => throw new NotImplementedException();
}
private sealed class FakePortfolioCoverStorage : IPortfolioCoverStorage
{
public string SaveKey { get; set; } = Guid.NewGuid().ToString("N") + ".png";
public List<string> SavedKeys { get; } = new();
public List<string> DeletedKeys { get; } = new();
public Task<PortfolioCoverUploadResult> SaveAsync(Stream content, string contentType, CancellationToken cancellationToken = default)
{
SavedKeys.Add(SaveKey);
return Task.FromResult(new PortfolioCoverUploadResult(SaveKey, contentType));
}
public Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default)
{
DeletedKeys.Add(storageKey);
return Task.CompletedTask;
}
public string GetPublicPath(string storageKey) => "/portfolio-covers/" + storageKey;
}
}