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 { 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 { [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( () => 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 { [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 { [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( () => 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 { [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 { [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 { [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( () => 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 { [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 { [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 { [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( () => 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 { [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 { [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 { [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( () => 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( () => 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 { [portfolioGameId] = null } }; var sessionStore = new FakeSessionStore(); var accessor = CreateAccessor("1001"); var service = new AuthorizedPortfolioService(portfolioStore, sessionStore, new FakePortfolioCoverStorage(), accessor); await Assert.ThrowsAsync( () => service.DeleteForCurrentUserAsync(portfolioGameId)); } // --- Fakes --- private sealed class FakePortfolioStore : IPortfolioStore { public Dictionary PortfolioGameGroupIds { get; set; } = new(); public Dictionary 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 EligibleSessions { get; set; } = []; public Guid? LastEligibleGroupId { get; private set; } public IReadOnlyList 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> GetPublicPortfolioGamesForMasterAsync(string masterSlug) => Task.FromResult>([]); public Task> GetPublicPortfolioGamesForClubAsync(string clubSlug) => Task.FromResult>([]); public Task GetPublicPortfolioGameBySlugAsync(string slug) => Task.FromResult(null); public Task> GetPortfolioGamesForGroupAsync(Guid groupId) { LastEligibleGroupId = groupId; return Task.FromResult(GamesForGroup); } public Task GetPortfolioGameGroupIdAsync(Guid portfolioGameId) { PortfolioGameGroupIds.TryGetValue(portfolioGameId, out var groupId); return Task.FromResult(groupId); } public Task GetPortfolioGameForManagementAsync(Guid portfolioGameId) => Task.FromResult(EditorResult); public Task> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId) { LastEligibleGroupId = groupId; return Task.FromResult(EligibleSessions); } public Task> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId) => Task.FromResult>([]); public Task 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 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 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 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 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 ResolveEffectivePlayerIdAsync(string platform, string externalUserId) => Task.FromResult(EffectivePlayerId); // Unused interface members — throw so accidental use surfaces in test output public Task> GetGroupsForUserAsync(string platform, string externalUserId) => throw new NotImplementedException(); public Task GetGroupAsync(Guid groupId) => throw new NotImplementedException(); public Task 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 GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException(); public Task GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) => throw new NotImplementedException(); public Task IsActiveClubMemberAsync(Guid groupId, Guid playerId) => throw new NotImplementedException(); public Task GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) => throw new NotImplementedException(); public Task> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) => throw new NotImplementedException(); public Task GetPendingApplicationsCountAsync(Guid groupId) => throw new NotImplementedException(); public Task> GetPendingApplicationsAsync(Guid groupId) => throw new NotImplementedException(); public Task> GetMembershipsForPlayerAsync(Guid playerId) => throw new NotImplementedException(); public Task 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 GetGroupIdForMembershipAsync(Guid membershipId) => throw new NotImplementedException(); public Task IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) => throw new NotImplementedException(); public Task> GetGroupManagersAsync(Guid groupId) => throw new NotImplementedException(); public Task> GetUpcomingSessionsAsync(Guid groupId) => throw new NotImplementedException(); public Task GetSessionAsync(Guid sessionId) => throw new NotImplementedException(); public Task 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 CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval) => throw new NotImplementedException(); public Task> GetCampaignTemplatesAsync(Guid groupId) => throw new NotImplementedException(); public Task GetCampaignTemplateAsync(Guid templateId) => throw new NotImplementedException(); public Task CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request) => throw new NotImplementedException(); public Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId) => throw new NotImplementedException(); public Task 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> GetSessionParticipantsAsync(Guid sessionId) => throw new NotImplementedException(); public Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId) => throw new NotImplementedException(); public Task> 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> GetSessionHistoryAsync(Guid sessionId) => throw new NotImplementedException(); public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) => throw new NotImplementedException(); public Task 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 GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException(); public Task> 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> GetShowcaseSessionsAsync(GmRelay.Shared.Features.Showcase.ShowcaseFilter filter, int page, int pageSize) => throw new NotImplementedException(); public Task GetShowcaseSessionAsync(Guid sessionId) => throw new NotImplementedException(); public Task 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 SavedKeys { get; } = new(); public List DeletedKeys { get; } = new(); public Task 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; } }