diff --git a/src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs b/src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs new file mode 100644 index 0000000..91f523a --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs @@ -0,0 +1,36 @@ +namespace GmRelay.Web.Services.Portfolio; + +public interface IPortfolioStore +{ + Task> GetPublicPortfolioGamesForMasterAsync(string masterSlug); + + Task> GetPublicPortfolioGamesForClubAsync(string clubSlug); + + Task GetPublicPortfolioGameBySlugAsync(string slug); + + Task> GetPortfolioGamesForGroupAsync(Guid groupId); + + Task GetPortfolioGameGroupIdAsync(Guid portfolioGameId); + + Task GetPortfolioGameForManagementAsync(Guid portfolioGameId); + + Task> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId); + + Task> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId); + + Task CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId); + + Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update); + + Task SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey); + + Task DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId); + + Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic); + + Task ModeratePortfolioReviewAsync(Guid reviewId, Guid portfolioGameId, Guid groupId, Guid moderatorPlayerId, string moderationStatus); + + Task GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId); + + Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body); +} diff --git a/src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs b/src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs new file mode 100644 index 0000000..60424a2 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs @@ -0,0 +1,90 @@ +namespace GmRelay.Web.Services.Portfolio; + +public sealed record PublicPortfolioCard( + string Slug, + string Title, + string CoverPath, + string? System, + string? Format, + DateTime CompletedAt); + +public sealed record PublicPortfolioMaster(string Slug, string DisplayName); + +public sealed record PublicPortfolioReview( + string AuthorDisplayName, + string Body, + DateTime CreatedAt); + +public sealed record PublicPortfolioGame( + string Slug, + string Title, + string Description, + string CoverPath, + string? System, + string? Format, + DateTime CompletedAt, + string? ClubName, + string? ClubSlug, + IReadOnlyList Masters, + IReadOnlyList Reviews); + +public sealed record PortfolioGameSummary( + Guid Id, + Guid GroupId, + string Title, + string? PublicSlug, + bool IsPublic, + DateTime CompletedAt, + int SessionCount, + int MasterCount, + int PendingReviewCount); + +public sealed record PortfolioSessionOption( + Guid Id, + string Title, + DateTime ScheduledAt, + bool Selected); + +public sealed record PortfolioMasterOption( + Guid PlayerId, + string DisplayName, + bool Selected); + +public sealed record PortfolioReviewForModeration( + Guid Id, + string AuthorDisplayName, + string Body, + string ModerationStatus, + DateTime CreatedAt); + +public sealed record PortfolioGameEditor( + Guid Id, + Guid GroupId, + string Title, + string? PublicSlug, + string? Description, + string? CoverPath, + string? System, + string? Format, + DateTime CompletedAt, + bool IsPublic, + IReadOnlyList Sessions, + IReadOnlyList Masters, + IReadOnlyList Reviews); + +public sealed record PortfolioGameUpdate( + string Title, + string? PublicSlug, + string? Description, + string? System, + string? Format, + IReadOnlyList SessionIds, + IReadOnlyList MasterPlayerIds); + +public enum PortfolioReviewSubmissionState +{ + RequiresAuthentication, + Ineligible, + Eligible, + AlreadySubmitted +} diff --git a/src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs b/src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs new file mode 100644 index 0000000..c15c59f --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs @@ -0,0 +1,152 @@ +using System.Text; + +namespace GmRelay.Web.Services.Portfolio; + +public static class PortfolioValidation +{ + private const int MinSlugLength = 3; + private const int MaxSlugLength = 160; + private const int MinTitleLength = 2; + private const int MaxTitleLength = 255; + private const int MaxDescriptionLength = 5000; + private const int MinReviewBodyLength = 10; + private const int MaxReviewBodyLength = 2000; + + private static readonly HashSet AllowedFormats = new(StringComparer.Ordinal) + { + "Online", + "Offline", + "Hybrid" + }; + + public static string NormalizeSlug(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException("Slug must not be empty."); + } + + var trimmed = value.Trim().ToLowerInvariant(); + + var builder = new StringBuilder(trimmed.Length); + var previousWasHyphen = false; + foreach (var raw in trimmed) + { + char c; + if (raw == ' ' || raw == '_' || raw == '-') + { + c = '-'; + } + else if (IsAsciiAlphanumeric(raw)) + { + c = raw; + } + else + { + throw new InvalidOperationException($"Slug contains unsupported character: '{raw}'."); + } + + if (c == '-') + { + if (builder.Length == 0 || previousWasHyphen) + { + continue; + } + + builder.Append('-'); + previousWasHyphen = true; + } + else + { + builder.Append(c); + previousWasHyphen = false; + } + } + + while (builder.Length > 0 && builder[^1] == '-') + { + builder.Length--; + } + + if (builder.Length < MinSlugLength || builder.Length > MaxSlugLength) + { + throw new InvalidOperationException( + $"Slug length must be between {MinSlugLength} and {MaxSlugLength} characters."); + } + + // The normalization loop guarantees the output matches ^[a-z0-9]+(?:-[a-z0-9]+)*$, + // so no post-loop regex check is required. + return builder.ToString(); + } + + public static string NormalizeTitle(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException("Title must not be empty."); + } + + var trimmed = value.Trim(); + if (trimmed.Length < MinTitleLength || trimmed.Length > MaxTitleLength) + { + throw new InvalidOperationException( + $"Title length must be between {MinTitleLength} and {MaxTitleLength} characters."); + } + + return trimmed; + } + + public static string? NormalizeDescription(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + if (trimmed.Length > MaxDescriptionLength) + { + throw new InvalidOperationException( + $"Description must be at most {MaxDescriptionLength} characters."); + } + + return trimmed; + } + + public static string NormalizeReviewBody(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException("Review body must not be empty."); + } + + var trimmed = value.Trim(); + if (trimmed.Length < MinReviewBodyLength || trimmed.Length > MaxReviewBodyLength) + { + throw new InvalidOperationException( + $"Review body length must be between {MinReviewBodyLength} and {MaxReviewBodyLength} characters."); + } + + return trimmed; + } + + public static string? NormalizeFormat(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + if (!AllowedFormats.Contains(trimmed)) + { + throw new InvalidOperationException( + $"Format must be one of: {string.Join(", ", AllowedFormats)}."); + } + + return trimmed; + } + + private static bool IsAsciiAlphanumeric(char c) => + (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs new file mode 100644 index 0000000..569ebe5 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs @@ -0,0 +1,120 @@ +using GmRelay.Web.Services.Portfolio; + +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioContractsTests +{ + [Fact] + public void PublicPortfolioCard_ShouldExposeOnlySanitizedPublicProperties() + { + AssertNoForbiddenPropertyNames(); + } + + [Fact] + public void PublicPortfolioGame_ShouldExposeOnlySanitizedPublicProperties() + { + AssertNoForbiddenPropertyNames(); + } + + [Fact] + public void PublicPortfolioMaster_ShouldExposeOnlySanitizedPublicProperties() + { + AssertNoForbiddenPropertyNames(); + } + + [Fact] + public void PublicPortfolioReview_ShouldExposeOnlySanitizedPublicProperties() + { + AssertNoForbiddenPropertyNames(); + } + + [Fact] + public void PublicPortfolioCard_ShouldExposeExpectedProperties() + { + var names = typeof(PublicPortfolioCard).GetProperties().Select(p => p.Name).ToArray(); + + Assert.Contains("Slug", names); + Assert.Contains("Title", names); + Assert.Contains("CoverPath", names); + Assert.Contains("System", names); + Assert.Contains("Format", names); + Assert.Contains("CompletedAt", names); + } + + [Fact] + public void PublicPortfolioGame_ShouldExposeExpectedProperties() + { + var names = typeof(PublicPortfolioGame).GetProperties().Select(p => p.Name).ToArray(); + + Assert.Contains("Slug", names); + Assert.Contains("Title", names); + Assert.Contains("Description", names); + Assert.Contains("CoverPath", names); + Assert.Contains("System", names); + Assert.Contains("Format", names); + Assert.Contains("CompletedAt", names); + Assert.Contains("ClubName", names); + Assert.Contains("ClubSlug", names); + Assert.Contains("Masters", names); + Assert.Contains("Reviews", names); + } + + [Fact] + public void PublicPortfolioMaster_ShouldExposeExpectedProperties() + { + var names = typeof(PublicPortfolioMaster).GetProperties().Select(p => p.Name).ToArray(); + + Assert.Contains("Slug", names); + Assert.Contains("DisplayName", names); + } + + [Fact] + public void PublicPortfolioReview_ShouldExposeExpectedProperties() + { + var names = typeof(PublicPortfolioReview).GetProperties().Select(p => p.Name).ToArray(); + + Assert.Contains("AuthorDisplayName", names); + Assert.Contains("Body", names); + Assert.Contains("CreatedAt", names); + } + + [Fact] + public void IPortfolioStore_ShouldExposeAllRequiredMethods() + { + var interfaceType = typeof(IPortfolioStore); + var methodNames = interfaceType.GetMethods().Select(m => m.Name).ToArray(); + + Assert.Contains("GetPublicPortfolioGamesForMasterAsync", methodNames); + Assert.Contains("GetPublicPortfolioGamesForClubAsync", methodNames); + Assert.Contains("GetPublicPortfolioGameBySlugAsync", methodNames); + Assert.Contains("GetPortfolioGamesForGroupAsync", methodNames); + Assert.Contains("GetPortfolioGameGroupIdAsync", methodNames); + Assert.Contains("GetPortfolioGameForManagementAsync", methodNames); + Assert.Contains("GetEligibleCompletedSessionsAsync", methodNames); + Assert.Contains("GetPortfolioMasterOptionsAsync", methodNames); + Assert.Contains("CreatePortfolioDraftAsync", methodNames); + Assert.Contains("UpdatePortfolioDraftAsync", methodNames); + Assert.Contains("SetPortfolioCoverAsync", methodNames); + Assert.Contains("DeletePortfolioGameAsync", methodNames); + Assert.Contains("SetPortfolioPublicationAsync", methodNames); + Assert.Contains("ModeratePortfolioReviewAsync", methodNames); + Assert.Contains("GetReviewSubmissionStateAsync", methodNames); + Assert.Contains("SubmitPortfolioReviewAsync", methodNames); + } + + private static void AssertNoForbiddenPropertyNames() + { + var forbidden = new[] + { + "Id", "External", "Telegram", "Discord", "Moderator", + "StorageKey", "PhysicalPath", "JoinLink", "Session" + }; + + var names = typeof(T).GetProperties().Select(p => p.Name).ToArray(); + + foreach (var forbiddenFragment in forbidden) + { + Assert.DoesNotContain(names, name => name.Contains(forbiddenFragment, StringComparison.Ordinal)); + } + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs new file mode 100644 index 0000000..be26500 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs @@ -0,0 +1,103 @@ +using GmRelay.Web.Services.Portfolio; + +namespace GmRelay.Bot.Tests.Web; + +public sealed class PortfolioValidationTests +{ + [Theory] + [InlineData(" Dragon Heist ", "dragon-heist")] + [InlineData("dragon_heist", "dragon-heist")] + [InlineData("Dragon Heist", "dragon-heist")] + [InlineData("dragon---heist", "dragon-heist")] + [InlineData("dragon-heist-", "dragon-heist")] + [InlineData("DRAGON-Heist", "dragon-heist")] + public void NormalizeSlug_ShouldReturnCanonicalSlug(string input, string expected) + { + Assert.Equal(expected, PortfolioValidation.NormalizeSlug(input)); + } + + [Theory] + [InlineData("")] + [InlineData("ab")] + [InlineData("spaces are fine after normalization but this slug is intentionally far too long to be accepted because it exceeds the maximum portfolio slug size of one hundred and sixty characters")] + [InlineData("кириллица")] + [InlineData("hello world!")] + [InlineData("---")] + public void NormalizeSlug_ShouldRejectInvalidSlug(string input) + { + Assert.Throws(() => PortfolioValidation.NormalizeSlug(input)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void NormalizeReviewBody_ShouldRejectBlankText(string? body) + { + Assert.Throws(() => PortfolioValidation.NormalizeReviewBody(body)); + } + + [Theory] + [InlineData("This is a perfectly valid review body for the portfolio entry that should pass.")] + [InlineData(" Another valid review body that meets the minimum length requirement. ")] + public void NormalizeReviewBody_ShouldTrimAndAcceptValidText(string body) + { + Assert.Equal(body.Trim(), PortfolioValidation.NormalizeReviewBody(body)); + } + + [Theory] + [InlineData(" Hello World ")] + [InlineData("abc")] + public void NormalizeTitle_ShouldTrimAndAcceptValidText(string title) + { + Assert.Equal(title.Trim(), PortfolioValidation.NormalizeTitle(title)); + } + + [Theory] + [InlineData("")] + [InlineData("a")] + public void NormalizeTitle_ShouldRejectTooShort(string title) + { + Assert.Throws(() => PortfolioValidation.NormalizeTitle(title)); + } + + [Fact] + public void NormalizeDescription_ShouldReturnNullForWhitespace() + { + Assert.Null(PortfolioValidation.NormalizeDescription(null)); + Assert.Null(PortfolioValidation.NormalizeDescription("")); + Assert.Null(PortfolioValidation.NormalizeDescription(" ")); + } + + [Fact] + public void NormalizeDescription_ShouldTrimAndAcceptValidText() + { + Assert.Equal("hello", PortfolioValidation.NormalizeDescription(" hello ")); + } + + [Theory] + [InlineData("Online")] + [InlineData("Offline")] + [InlineData("Hybrid")] + public void NormalizeFormat_ShouldAcceptKnownValues(string format) + { + Assert.Equal(format, PortfolioValidation.NormalizeFormat(format)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void NormalizeFormat_ShouldReturnNullForBlank(string? format) + { + Assert.Null(PortfolioValidation.NormalizeFormat(format)); + } + + [Theory] + [InlineData("online")] + [InlineData("VTT")] + public void NormalizeFormat_ShouldRejectUnknownValues(string format) + { + Assert.Throws(() => PortfolioValidation.NormalizeFormat(format)); + } +}