diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index 74030e5..66318ea 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -47,6 +47,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); // Add Bot Client builder.Services.AddSingleton(sp => diff --git a/src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs b/src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs new file mode 100644 index 0000000..ef81884 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs @@ -0,0 +1,258 @@ +using System.Security.Claims; +using GmRelay.Web.Services.Portfolio.Covers; + +namespace GmRelay.Web.Services.Portfolio; + +public sealed class AuthorizedPortfolioService( + IPortfolioStore portfolioStore, + ISessionStore sessionStore, + IPortfolioCoverStorage coverStorage, + IHttpContextAccessor httpContextAccessor) +{ + private (string Platform, string ExternalUserId, string? Name)? GetCurrentIdentity() + { + var user = httpContextAccessor.HttpContext?.User; + if (user is null || !user.TryGetPlatformIdentity(out var platform, out var externalUserId)) + return null; + + var name = user.FindFirst(ClaimTypes.Name)?.Value; + return (platform, externalUserId, name); + } + + private async Task<(string Platform, string ExternalUserId)> RequireManagerAsync(Guid groupId) + { + var identity = GetCurrentIdentity(); + if (identity is null) + { + throw new SessionAccessDeniedException(groupId, ""); + } + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) + { + throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId); + } + + return (identity.Value.Platform, identity.Value.ExternalUserId); + } + + private async Task<(Guid GroupId, string Platform, string ExternalUserId)> RequireManagerForGameAsync(Guid portfolioGameId) + { + var identity = GetCurrentIdentity(); + if (identity is null) + { + throw new SessionAccessDeniedException(portfolioGameId, ""); + } + + var groupId = await portfolioStore.GetPortfolioGameGroupIdAsync(portfolioGameId); + if (groupId is null) + { + throw new InvalidOperationException("Portfolio game not found."); + } + + if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId)) + { + throw new SessionAccessDeniedException(portfolioGameId, identity.Value.ExternalUserId); + } + + return (groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId); + } + + // --- Protected reads --- + + public async Task> GetPortfolioGamesForCurrentUserAsync(Guid groupId) + { + var identity = GetCurrentIdentity(); + if (identity is null) + { + return []; + } + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) + { + return []; + } + + return await portfolioStore.GetPortfolioGamesForGroupAsync(groupId); + } + + public async Task GetPortfolioGameForCurrentUserAsync(Guid portfolioGameId) + { + var identity = GetCurrentIdentity(); + if (identity is null) + { + return null; + } + + var groupId = await portfolioStore.GetPortfolioGameGroupIdAsync(portfolioGameId); + if (groupId is null) + { + return null; + } + + if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId)) + { + return null; + } + + return await portfolioStore.GetPortfolioGameForManagementAsync(portfolioGameId); + } + + public async Task> GetCompletedSessionsForCurrentUserAsync(Guid groupId) + { + var identity = GetCurrentIdentity(); + if (identity is null) + { + return []; + } + + if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId)) + { + return []; + } + + return await portfolioStore.GetEligibleCompletedSessionsAsync(groupId, null); + } + + // --- Protected writes --- + + public async Task CreateDraftForCurrentUserAsync(Guid groupId, Guid? preselectedSessionId) + { + await RequireManagerAsync(groupId); + return await portfolioStore.CreatePortfolioDraftAsync(groupId, preselectedSessionId); + } + + public async Task UpdateDraftForCurrentUserAsync(Guid portfolioGameId, PortfolioGameUpdate update) + { + var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId); + + var normalized = NormalizeUpdate(update); + await portfolioStore.UpdatePortfolioDraftAsync(portfolioGameId, groupId, normalized); + } + + public async Task ReplaceCoverForCurrentUserAsync( + Guid portfolioGameId, + Stream content, + string contentType, + CancellationToken cancellationToken = default) + { + var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId); + + var saveResult = await coverStorage.SaveAsync(content, contentType, cancellationToken); + var newKey = saveResult.StorageKey; + + try + { + var oldKey = await portfolioStore.SetPortfolioCoverAsync(portfolioGameId, groupId, newKey); + if (!string.IsNullOrWhiteSpace(oldKey)) + { + await coverStorage.DeleteIfExistsAsync(oldKey, cancellationToken); + } + } + catch + { + await coverStorage.DeleteIfExistsAsync(newKey, cancellationToken); + throw; + } + } + + public async Task DeleteForCurrentUserAsync(Guid portfolioGameId) + { + var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId); + + var coverKey = await portfolioStore.DeletePortfolioGameAsync(portfolioGameId, groupId); + if (!string.IsNullOrWhiteSpace(coverKey)) + { + await coverStorage.DeleteIfExistsAsync(coverKey); + } + } + + public async Task SetPublicationForCurrentUserAsync(Guid portfolioGameId, bool isPublic) + { + var (groupId, _, _) = await RequireManagerForGameAsync(portfolioGameId); + await portfolioStore.SetPortfolioPublicationAsync(portfolioGameId, groupId, isPublic); + } + + public async Task ModerateReviewForCurrentUserAsync( + Guid portfolioGameId, + Guid reviewId, + string moderationStatus) + { + var (groupId, platform, externalUserId) = await RequireManagerForGameAsync(portfolioGameId); + + var moderatorPlayerId = await sessionStore.ResolveEffectivePlayerIdAsync(platform, externalUserId); + if (moderatorPlayerId is null) + { + throw new InvalidOperationException("Authenticated player not found."); + } + + await portfolioStore.ModeratePortfolioReviewAsync( + reviewId, + portfolioGameId, + groupId, + moderatorPlayerId.Value, + moderationStatus); + } + + // --- Review submission --- + + public async Task GetReviewSubmissionStateForCurrentUserAsync(string slug) + { + var identity = GetCurrentIdentity(); + if (identity is null) + { + return PortfolioReviewSubmissionState.RequiresAuthentication; + } + + return await portfolioStore.GetReviewSubmissionStateAsync(slug, identity.Value.Platform, identity.Value.ExternalUserId); + } + + public async Task SubmitReviewForCurrentUserAsync(string slug, string body, bool publicationConsent) + { + if (!publicationConsent) + { + throw new InvalidOperationException("Public review requires explicit consent."); + } + + var identity = GetCurrentIdentity(); + if (identity is null) + { + throw new SessionAccessDeniedException(Guid.Empty, ""); + } + + var normalizedSlug = PortfolioValidation.NormalizeSlug(slug); + var normalizedBody = PortfolioValidation.NormalizeReviewBody(body); + + var displayName = identity.Value.Name?.Trim() ?? identity.Value.ExternalUserId; + if (displayName.Length == 0) + { + throw new InvalidOperationException("Display name is required."); + } + + await portfolioStore.SubmitPortfolioReviewAsync( + normalizedSlug, + identity.Value.Platform, + identity.Value.ExternalUserId, + displayName, + normalizedBody); + } + + // --- Internal helpers --- + + private static PortfolioGameUpdate NormalizeUpdate(PortfolioGameUpdate update) + { + var title = PortfolioValidation.NormalizeTitle(update.Title); + var slug = string.IsNullOrWhiteSpace(update.PublicSlug) ? null : PortfolioValidation.NormalizeSlug(update.PublicSlug); + var description = PortfolioValidation.NormalizeDescription(update.Description); + var format = PortfolioValidation.NormalizeFormat(update.Format); + var system = string.IsNullOrWhiteSpace(update.System) ? null : update.System.Trim(); + + return update with + { + Title = title, + PublicSlug = slug, + Description = description, + System = system, + Format = format + }; + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs new file mode 100644 index 0000000..2e43048 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs @@ -0,0 +1,857 @@ +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 SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic) => throw new NotImplementedException(); + public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic) => throw new NotImplementedException(); + public Task GetPublicClubBySlugAsync(string slug) => throw new NotImplementedException(); + public Task GetPublicSessionAsync(Guid sessionId) => 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) => 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; + } +}