feat(web): define portfolio contracts and validation
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
namespace GmRelay.Web.Services.Portfolio;
|
||||||
|
|
||||||
|
public interface IPortfolioStore
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug);
|
||||||
|
|
||||||
|
Task<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId);
|
||||||
|
|
||||||
|
Task<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId);
|
||||||
|
|
||||||
|
Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId);
|
||||||
|
|
||||||
|
Task<Guid> CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId);
|
||||||
|
|
||||||
|
Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update);
|
||||||
|
|
||||||
|
Task<string?> SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey);
|
||||||
|
|
||||||
|
Task<string?> 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<PortfolioReviewSubmissionState> GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId);
|
||||||
|
|
||||||
|
Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body);
|
||||||
|
}
|
||||||
@@ -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<PublicPortfolioMaster> Masters,
|
||||||
|
IReadOnlyList<PublicPortfolioReview> 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<PortfolioSessionOption> Sessions,
|
||||||
|
IReadOnlyList<PortfolioMasterOption> Masters,
|
||||||
|
IReadOnlyList<PortfolioReviewForModeration> Reviews);
|
||||||
|
|
||||||
|
public sealed record PortfolioGameUpdate(
|
||||||
|
string Title,
|
||||||
|
string? PublicSlug,
|
||||||
|
string? Description,
|
||||||
|
string? System,
|
||||||
|
string? Format,
|
||||||
|
IReadOnlyList<Guid> SessionIds,
|
||||||
|
IReadOnlyList<Guid> MasterPlayerIds);
|
||||||
|
|
||||||
|
public enum PortfolioReviewSubmissionState
|
||||||
|
{
|
||||||
|
RequiresAuthentication,
|
||||||
|
Ineligible,
|
||||||
|
Eligible,
|
||||||
|
AlreadySubmitted
|
||||||
|
}
|
||||||
@@ -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<string> 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');
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using GmRelay.Web.Services.Portfolio;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public sealed class PortfolioContractsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void PublicPortfolioCard_ShouldExposeOnlySanitizedPublicProperties()
|
||||||
|
{
|
||||||
|
AssertNoForbiddenPropertyNames<PublicPortfolioCard>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PublicPortfolioGame_ShouldExposeOnlySanitizedPublicProperties()
|
||||||
|
{
|
||||||
|
AssertNoForbiddenPropertyNames<PublicPortfolioGame>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PublicPortfolioMaster_ShouldExposeOnlySanitizedPublicProperties()
|
||||||
|
{
|
||||||
|
AssertNoForbiddenPropertyNames<PublicPortfolioMaster>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PublicPortfolioReview_ShouldExposeOnlySanitizedPublicProperties()
|
||||||
|
{
|
||||||
|
AssertNoForbiddenPropertyNames<PublicPortfolioReview>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<T>()
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<InvalidOperationException>(() => PortfolioValidation.NormalizeSlug(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void NormalizeReviewBody_ShouldRejectBlankText(string? body)
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => PortfolioValidation.NormalizeFormat(format));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user