diff --git a/src/Captcha.Core/Constants.cs b/src/Captcha.Core/Constants.cs index 019d738..354a78f 100644 --- a/src/Captcha.Core/Constants.cs +++ b/src/Captcha.Core/Constants.cs @@ -4,8 +4,6 @@ namespace Captcha.Core; public static class Constants { - public const int MinCaptchaSize = 10; - public const int MaxCaptchaSize = 1024; public const int DefaultCaptchaWidth = 400; public const int DefaultCaptchaHeight = 100; public const float DefaultFrequency = 100F; @@ -16,11 +14,15 @@ public static class Constants public const float WarpCaptchaTextFrequency = 4F; public const int CaptchaNoise = 50; - // According to https://learn.microsoft.com/en-us/dotnet/api/skiasharp.sktypeface, this should be thread-safe + /// + /// This object is shared across threads, however according to https://learn.microsoft.com/en-us/dotnet/api/skiasharp.sktypeface it is fine to share it across threads. + /// public static SKTypeface MainFontTypeface { get; } = SKTypeface.FromStream(typeof(Constants).Assembly.GetManifestResourceStream("Captcha.Core.Resources.Fonts.Caveat-SemiBold.ttf")); - // According to https://learn.microsoft.com/en-us/dotnet/api/skiasharp.sktypeface, this should be thread-safe + /// + /// This object is shared across threads, however according to https://learn.microsoft.com/en-us/dotnet/api/skiasharp.sktypeface it is fine to share it across threads. + /// public static SKTypeface FallbackFontTypeface { get; } = SKTypeface.FromStream(typeof(Constants).Assembly.GetManifestResourceStream("Captcha.Core.Resources.Fonts.Unifont.ttf")); } diff --git a/src/Captcha.Core/Mappers/RequestToDomainMapper.cs b/src/Captcha.Core/Mappers/RequestToDomainMapper.cs index 805c939..01c537d 100644 --- a/src/Captcha.Core/Mappers/RequestToDomainMapper.cs +++ b/src/Captcha.Core/Mappers/RequestToDomainMapper.cs @@ -6,7 +6,23 @@ namespace Captcha.Core.Mappers; public class RequestToDomainMapper { - public CaptchaConfigurationData ToDomain(CaptchaRequest request) + public CaptchaConfigurationData ToDomain(GetCreateCaptchaRequest request) + { + var width = request.Width ?? Constants.DefaultCaptchaWidth; + var height = request.Height ?? Constants.DefaultCaptchaHeight; + + return new CaptchaConfigurationData + { + Text = request.Text, + Width = width, + Height = height, + Frequency = GetFrequency(request.Difficulty, width, height), + PrimaryColor = GetColor(request.Theme?.PrimaryColor) ?? Constants.DefaultPrimaryColor, + SecondaryColor = GetColor(request.Theme?.SecondaryColor) ?? Constants.DefaultSecondaryColor, + }; + } + + public CaptchaConfigurationData ToDomain(PostCreateCaptchaRequest request) { var width = request.Width ?? Constants.DefaultCaptchaWidth; var height = request.Height ?? Constants.DefaultCaptchaHeight; diff --git a/src/Captcha.Core/Models/CaptchaRequest.cs b/src/Captcha.Core/Models/GetCreateCaptchaRequest.cs similarity index 87% rename from src/Captcha.Core/Models/CaptchaRequest.cs rename to src/Captcha.Core/Models/GetCreateCaptchaRequest.cs index 98a88dc..3391101 100644 --- a/src/Captcha.Core/Models/CaptchaRequest.cs +++ b/src/Captcha.Core/Models/GetCreateCaptchaRequest.cs @@ -1,6 +1,6 @@ namespace Captcha.Core.Models; -public record CaptchaRequest +public record GetCreateCaptchaRequest { public required string Text { get; init; } public int? Width { get; init; } diff --git a/src/Captcha.Core/Models/PostCreateCaptchaRequest.cs b/src/Captcha.Core/Models/PostCreateCaptchaRequest.cs new file mode 100644 index 0000000..e1b1ae5 --- /dev/null +++ b/src/Captcha.Core/Models/PostCreateCaptchaRequest.cs @@ -0,0 +1,10 @@ +namespace Captcha.Core.Models; + +public record PostCreateCaptchaRequest +{ + public required string Text { get; init; } + public int? Width { get; init; } + public int? Height { get; init; } + public CaptchaDifficulty? Difficulty { get; init; } + public ThemeConfiguration? Theme { get; set; } +} diff --git a/src/Captcha.WebApi/Controllers/CaptchaController.cs b/src/Captcha.WebApi/Controllers/CaptchaController.cs index 33afac3..f85941f 100644 --- a/src/Captcha.WebApi/Controllers/CaptchaController.cs +++ b/src/Captcha.WebApi/Controllers/CaptchaController.cs @@ -13,18 +13,37 @@ namespace Captcha.WebApi.Controllers; [Route("[controller]")] public class CaptchaController(ICaptchaImageService captchaImageService, RequestToDomainMapper requestToDomainMapper) : ControllerBase { + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task GetCreateAsync([FromQuery] GetCreateCaptchaRequest request) + { + var domain = requestToDomainMapper.ToDomain(request); + + using var created = captchaImageService.CreateCaptchaImage(domain); + + return await SerializeToJpegFile(created); + } + [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - [SwaggerRequestExample(typeof(CaptchaRequest), typeof(CreateCaptchaExamples))] - public async Task CreateAsync(CaptchaRequest request) + [SwaggerRequestExample(typeof(PostCreateCaptchaRequest), typeof(CreateCaptchaExamples))] + public async Task PostCreateAsync(PostCreateCaptchaRequest request) { var domain = requestToDomainMapper.ToDomain(request); + using var created = captchaImageService.CreateCaptchaImage(domain); + return await SerializeToJpegFile(created); + } + + private static async Task SerializeToJpegFile(SKBitmap image) + { await using var memoryStream = new MemoryStream(); - SKImage.FromBitmap(created) + SKImage.FromBitmap(image) .Encode(SKEncodedImageFormat.Jpeg, 100) .SaveTo(memoryStream); diff --git a/src/Captcha.WebApi/Controllers/Examples/CreateCaptchaExamples.cs b/src/Captcha.WebApi/Controllers/Examples/CreateCaptchaExamples.cs index c4c3315..1e9497e 100644 --- a/src/Captcha.WebApi/Controllers/Examples/CreateCaptchaExamples.cs +++ b/src/Captcha.WebApi/Controllers/Examples/CreateCaptchaExamples.cs @@ -3,14 +3,14 @@ namespace Captcha.WebApi.Controllers.Examples; using Captcha.Core.Models; using Swashbuckle.AspNetCore.Filters; -public record CreateCaptchaExamples : IMultipleExamplesProvider +public record CreateCaptchaExamples : IMultipleExamplesProvider { - public IEnumerable> GetExamples() + public IEnumerable> GetExamples() { yield return SwaggerExample.Create( "Example 1", "Example 1 - Create captcha with text", - new CaptchaRequest + new PostCreateCaptchaRequest { Text = "hello world" }); @@ -18,7 +18,7 @@ public IEnumerable> GetExamples() yield return SwaggerExample.Create( "Example 2", "Example 2 - Create challenging captcha with text", - new CaptchaRequest + new PostCreateCaptchaRequest { Text = "hello world", Difficulty = CaptchaDifficulty.Challenging @@ -27,7 +27,7 @@ public IEnumerable> GetExamples() yield return SwaggerExample.Create( "Example 3", "Example 3 - Create hard captcha with text", - new CaptchaRequest + new PostCreateCaptchaRequest { Text = "hello world", Difficulty = CaptchaDifficulty.Hard @@ -36,7 +36,7 @@ public IEnumerable> GetExamples() yield return SwaggerExample.Create( "Example 4", "Example 4 - Create captcha with text and height and width", - new CaptchaRequest + new PostCreateCaptchaRequest { Text = "world", Height = 300, @@ -46,7 +46,7 @@ public IEnumerable> GetExamples() yield return SwaggerExample.Create( "Example 5", "Example 5 - Create captcha with a color theme", - new CaptchaRequest + new PostCreateCaptchaRequest { Text = "hello world", Theme = new ThemeConfiguration diff --git a/tests/Captcha.FunctionalTests/Features/CaptchaGetEndpoint.feature b/tests/Captcha.FunctionalTests/Features/CaptchaGetEndpoint.feature new file mode 100644 index 0000000..13352b5 --- /dev/null +++ b/tests/Captcha.FunctionalTests/Features/CaptchaGetEndpoint.feature @@ -0,0 +1,32 @@ +Feature: CaptchaGet +I want to send different captcha requests and assure the image is generated + + Scenario Outline: Send captcha requests + Given I have a captcha request using get with following parameters: + | Text | Width | Height | Difficulty | + | | | | | + When I send the get request to the Create endpoint of the CaptchaController + Then I expect a captcha image to be returned with the following attributes: + | Width | Height | + | | | + Then I expect a captcha image to contain at least '' pixels of color '' and at least '' pixels of color '' + + Examples: + | Text | Width | Height | Difficulty | ExpectedWidth | ExpectedHeight | FirstColorPixels | FirstColorHex | SecondColorPixels | SecondColorHex | + | مرحبًا | | | | 400 | 100 | 2300 | #FFD3D3D3 | 30000 | #FFFFFFFF | + | 你好 | | | | 400 | 100 | 2500 | #FFD3D3D3 | 30000 | #FFFFFFFF | + | こんにちは | | | | 400 | 100 | 2500 | #FFD3D3D3 | 30000 | #FFFFFFFF | + | 안녕하세요 | | | | 400 | 100 | 3000 | #FFD3D3D3 | 30000 | #FFFFFFFF | + | Здравствуйте | | | | 400 | 100 | 3000 | #FFD3D3D3 | 28000 | #FFFFFFFF | + | Bonjour | | | | 400 | 100 | 3000 | #FFD3D3D3 | 30000 | #FFFFFFFF | + | Guten Tag | | | | 400 | 100 | 3000 | #FFD3D3D3 | 30000 | #FFFFFFFF | + | Selam | | | | 400 | 100 | 2500 | #FFD3D3D3 | 30000 | #FFFFFFFF | + | Γεια σας | | | | 400 | 100 | 3000 | #FFD3D3D3 | 30000 | #FFFFFFFF | + | Lorem | | | | 400 | 100 | 2500 | #FFD3D3D3 | 30000 | #FFFFFFFF | + | Ipsum | | 200 | | 400 | 200 | 6000 | #FFD3D3D3 | 60000 | #FFFFFFFF | + | helloworld | 200 | | Easy | 200 | 100 | 550 | #FFD3D3D3 | 10000 | #FFFFFFFF | + | bar | 300 | 300 | Medium | 300 | 300 | 4500 | #FFD3D3D3 | 70000 | #FFFFFFFF | + | foo | 400 | 400 | Hard | 400 | 400 | 6000 | #FFD3D3D3 | 110000 | #FFFFFFFF | + | Ciao | 200 | | Easy | 200 | 100 | 850 | #FFD3D3D3 | 10000 | #FFFFFFFF | + | Olá | 300 | 300 | Challenging | 300 | 300 | 6000 | #FFD3D3D3 | 64000 | #FFFFFFFF | + | สวัสดี | 400 | 400 | Hard | 400 | 400 | 12000 | #FFD3D3D3 | 120000 | #FFFFFFFF | diff --git a/tests/Captcha.FunctionalTests/Features/Captcha.feature b/tests/Captcha.FunctionalTests/Features/CaptchaPostEndpoint.feature similarity index 99% rename from tests/Captcha.FunctionalTests/Features/Captcha.feature rename to tests/Captcha.FunctionalTests/Features/CaptchaPostEndpoint.feature index 7a60723..4220a1b 100644 --- a/tests/Captcha.FunctionalTests/Features/Captcha.feature +++ b/tests/Captcha.FunctionalTests/Features/CaptchaPostEndpoint.feature @@ -1,4 +1,4 @@ -Feature: Captcha +Feature: CaptchaPost I want to send different captcha requests and assure the image is generated Scenario Outline: Send captcha requests diff --git a/tests/Captcha.FunctionalTests/StepDefinitions/CaptchaSteps.cs b/tests/Captcha.FunctionalTests/StepDefinitions/CaptchaSteps.cs index fcf515d..89de591 100644 --- a/tests/Captcha.FunctionalTests/StepDefinitions/CaptchaSteps.cs +++ b/tests/Captcha.FunctionalTests/StepDefinitions/CaptchaSteps.cs @@ -11,7 +11,9 @@ namespace Captcha.FunctionalTests.StepDefinitions; [Binding] public class CaptchaSteps(ScenarioContext context) : TestBase(context) { - private CaptchaRequest? _request; + private GetCreateCaptchaRequest? _getRequest; + private PostCreateCaptchaRequest? _postRequest; + private RestResponse? _response; [Given(@"I have a captcha request with following parameters:")] @@ -19,7 +21,7 @@ public void GivenIHaveACaptchaRequestWithFollowingParameters(Table table) { var row = table.Rows[0]; - _request = new CaptchaRequest + _postRequest = new PostCreateCaptchaRequest { Text = row[TestConstants.Text], Width = string.IsNullOrEmpty(row[TestConstants.Width]) @@ -50,7 +52,7 @@ public async Task WhenISendTheRequestToTheCreateEndpointOfTheCaptchaController() { RequestFormat = DataFormat.Json, Method = Method.Post - }.AddJsonBody(_request); + }.AddJsonBody(_postRequest); _response = await Client.ExecuteAsync(request); } @@ -128,4 +130,42 @@ public void ThenIExpectACaptchaImageToContainPixelsOfColorAndPixelsOfColor Assert.That(firstColorActualAmount, Is.AtLeast(firstColorExpectedAmount)); Assert.That(secondColorActualAmount, Is.AtLeast(secondColorExpectedAmount)); } + + [Given("I have a captcha request using get with following parameters:")] + public void GivenIHaveACaptchaRequestUsingGetWithFollowingParameters(Table table) + { + var row = table.Rows[0]; + + _getRequest = new GetCreateCaptchaRequest + { + Text = row[TestConstants.Text], + Width = string.IsNullOrEmpty(row[TestConstants.Width]) + ? null + : int.Parse(row[TestConstants.Width], CultureInfo.InvariantCulture), + Height = string.IsNullOrEmpty(row[TestConstants.Height]) + ? null + : int.Parse(row[TestConstants.Height], CultureInfo.InvariantCulture), + Difficulty = string.IsNullOrEmpty(row[TestConstants.Difficulty]) + ? null + : Enum.Parse(row[TestConstants.Difficulty], true), + Theme = new ThemeConfiguration() + { + PrimaryColor = !row.ContainsKey(TestConstants.PrimaryColor) + ? null + : row[TestConstants.PrimaryColor], + SecondaryColor = !row.ContainsKey(TestConstants.SecondaryColor) + ? null + : row[TestConstants.SecondaryColor] + } + }; + } + + [When("I send the get request to the Create endpoint of the CaptchaController")] + public async Task WhenISendTheGetRequestToTheCreateEndpointOfTheCaptchaController() + { + var request = new RestRequest(TestConstants.CreateCaptchaEndpoint) + .AddObject(_getRequest); + + _response = await Client.ExecuteAsync(request); + } } diff --git a/tests/Captcha.UnitTests/ConstantsTests.cs b/tests/Captcha.UnitTests/ConstantsTests.cs index 3091888..8a23faa 100644 --- a/tests/Captcha.UnitTests/ConstantsTests.cs +++ b/tests/Captcha.UnitTests/ConstantsTests.cs @@ -7,11 +7,6 @@ namespace Captcha.UnitTests; [TestFixture] public class ConstantsTests { - [Test] - public void MaxCaptchaSizeShouldBe1024() => Assert.That(Constants.MaxCaptchaSize, Is.EqualTo(1024)); - - [Test] - public void MinCaptchaSizeShouldBe10() => Assert.That(Constants.MinCaptchaSize, Is.EqualTo(10)); [Test] public void DefaultCaptchaWidthShouldBe400() => Assert.That(Constants.DefaultCaptchaWidth, Is.EqualTo(400)); diff --git a/tests/Captcha.UnitTests/Mappers/RequestToDomainMapperTests.cs b/tests/Captcha.UnitTests/Mappers/RequestToDomainMapperTests.cs index 1f3bf5a..8342239 100644 --- a/tests/Captcha.UnitTests/Mappers/RequestToDomainMapperTests.cs +++ b/tests/Captcha.UnitTests/Mappers/RequestToDomainMapperTests.cs @@ -17,7 +17,7 @@ public class RequestToDomainMapperTests public void ToDomainMapsTextCorrectly() { // Arrange - var request = new CaptchaRequest { Text = "test text" }; + var request = new PostCreateCaptchaRequest { Text = "test text" }; // Act var result = _requestToDomainMapper.ToDomain(request); @@ -30,7 +30,7 @@ public void ToDomainMapsTextCorrectly() public void ToDomainUsesDefaultWidthAndHeightWhenNotProvided() { // Arrange - var request = new CaptchaRequest { Text = "some text" }; + var request = new PostCreateCaptchaRequest { Text = "some text" }; // Act var result = _requestToDomainMapper.ToDomain(request); @@ -47,7 +47,7 @@ public void ToDomainUsesDefaultWidthAndHeightWhenNotProvided() public void ToDomainUsesProvidedWidthAndHeight() { // Arrange - var request = new CaptchaRequest + var request = new PostCreateCaptchaRequest { Width = 500, Height = 300, @@ -69,7 +69,7 @@ public void ToDomainUsesProvidedWidthAndHeight() public void ToDomainUsesDefaultFrequencyWhenDifficultyNotProvided() { // Arrange - var request = new CaptchaRequest { Text = "default freq" }; + var request = new PostCreateCaptchaRequest { Text = "default freq" }; // Act var result = _requestToDomainMapper.ToDomain(request); @@ -82,25 +82,25 @@ public void ToDomainUsesDefaultFrequencyWhenDifficultyNotProvided() public void ToDomainUsesCorrectFrequencyForDifficulty() { // Arrange - var easyRequest = new CaptchaRequest + var easyRequest = new PostCreateCaptchaRequest { Difficulty = CaptchaDifficulty.Easy, Text = "easy" }; - var mediumRequest = new CaptchaRequest + var mediumRequest = new PostCreateCaptchaRequest { Difficulty = CaptchaDifficulty.Medium, Text = "medium" }; - var challengingRequest = new CaptchaRequest + var challengingRequest = new PostCreateCaptchaRequest { Difficulty = CaptchaDifficulty.Challenging, Text = "challenging" }; - var hardRequest = new CaptchaRequest + var hardRequest = new PostCreateCaptchaRequest { Difficulty = CaptchaDifficulty.Hard, Text = "hard" @@ -126,7 +126,7 @@ public void ToDomainUsesCorrectFrequencyForDifficulty() public void ToDomainUsesDefaultFrequencyForUnrecognizedDifficulty() { // Arrange - var request = new CaptchaRequest + var request = new PostCreateCaptchaRequest { Difficulty = (CaptchaDifficulty)999, Text = "unknown difficulty" @@ -143,7 +143,7 @@ public void ToDomainUsesDefaultFrequencyForUnrecognizedDifficulty() public void ToDomainMapsMultipleRequestsCorrectly() { // Arrange - var requests = new List + var requests = new List { new() { @@ -183,7 +183,7 @@ public void ToDomainMapsMultipleRequestsCorrectly() public void ToDomainThrowsWhenRequestIsNull() { // Arrange - CaptchaRequest request = null; + PostCreateCaptchaRequest request = null; // Act & Assert Assert.Throws(() => _requestToDomainMapper.ToDomain(request)); @@ -193,7 +193,7 @@ public void ToDomainThrowsWhenRequestIsNull() public void ToDomainUsesDefaultPrimaryColorWhenNotProvided() { // Arrange - var request = new CaptchaRequest + var request = new PostCreateCaptchaRequest { Text = "text" }; @@ -213,7 +213,7 @@ public void ToDomainUsesDefaultPrimaryColorWhenNotProvided() public void ToDomainHandlesNullText() { // Arrange - var request = new CaptchaRequest + var request = new PostCreateCaptchaRequest { Text = null, Width = 100, @@ -236,7 +236,7 @@ public void ToDomainHandlesNullText() public void ToDomainHandlesExcessivelyLargeWidthAndHeight() { // Arrange - var request = new CaptchaRequest + var request = new PostCreateCaptchaRequest { Text = "Large dimensions", Width = int.MaxValue, @@ -259,7 +259,7 @@ public void ToDomainPreservesWhitespaceInText() { // Arrange var originalText = " Leading, internal and trailing whitespace "; - var request = new CaptchaRequest + var request = new PostCreateCaptchaRequest { Text = originalText }; @@ -277,7 +277,7 @@ public void ToDomainPreservesUnicodeText() { // Arrange var unicodeString = "Captcha 🚀 Test – 你好"; - var request = new CaptchaRequest + var request = new PostCreateCaptchaRequest { Text = unicodeString }; @@ -293,7 +293,7 @@ public void ToDomainPreservesUnicodeText() public void ToDomainAllowsEmptyText() { // Arrange - var request = new CaptchaRequest + var request = new PostCreateCaptchaRequest { Text = string.Empty }; @@ -310,7 +310,7 @@ public void ToDomainCanHandleExtremelyLongText() { // Arrange var longText = new string('x', 10_000); - var request = new CaptchaRequest + var request = new PostCreateCaptchaRequest { Text = longText }; @@ -327,7 +327,7 @@ public void ToDomainCanHandleExtremelyLongText() public void ToDomainUsesDefaultHeightWhenOnlyWidthProvided() { // Arrange - var request = new CaptchaRequest { Text = "w-only", Width = 321 }; + var request = new PostCreateCaptchaRequest { Text = "w-only", Width = 321 }; // Act var result = _requestToDomainMapper.ToDomain(request); @@ -344,7 +344,7 @@ public void ToDomainUsesDefaultHeightWhenOnlyWidthProvided() public void ToDomainUsesDefaultWidthWhenOnlyHeightProvided() { // Arrange - var request = new CaptchaRequest { Text = "h-only", Height = 654 }; + var request = new PostCreateCaptchaRequest { Text = "h-only", Height = 654 }; // Act var result = _requestToDomainMapper.ToDomain(request); @@ -361,7 +361,7 @@ public void ToDomainUsesDefaultWidthWhenOnlyHeightProvided() public void ToDomainUsesProvidedThemeColorsWhenValidHex() { // Arrange - var request = new CaptchaRequest + var request = new PostCreateCaptchaRequest { Text = "colors", Theme = new() @@ -386,7 +386,7 @@ public void ToDomainUsesProvidedThemeColorsWhenValidHex() public void ToDomainParsesHexCaseInsensitively() { // Arrange - var request = new CaptchaRequest + var request = new PostCreateCaptchaRequest { Text = "test", Theme = new() @@ -418,7 +418,7 @@ public void ToDomainParsesHexCaseInsensitively() public void ToDomainUsesDefaultColorsWhenThemeProvidedButEmptyOrWhitespace() { // Arrange - var request = new CaptchaRequest + var request = new PostCreateCaptchaRequest { Text = "empty theme", Theme = new() @@ -443,7 +443,7 @@ public void ToDomainUsesDefaultColorsWhenThemeProvidedButEmptyOrWhitespace() public void ToDomainUsesDefaultSecondaryColorWhenOnlyPrimaryProvided() { // Arrange - var request = new CaptchaRequest + var request = new PostCreateCaptchaRequest { Text = "only primary", Theme = new() @@ -468,7 +468,7 @@ public void ToDomainUsesDefaultSecondaryColorWhenOnlyPrimaryProvided() public void ToDomainUsesDefaultPrimaryColorWhenOnlySecondaryProvided() { // Arrange - var request = new CaptchaRequest + var request = new PostCreateCaptchaRequest { Text = "only secondary", Theme = new() @@ -489,4 +489,479 @@ public void ToDomainUsesDefaultPrimaryColorWhenOnlySecondaryProvided() } } + [Test] + public void GetRequestDomainMapsTextCorrectly() + { + // Arrange + var request = new GetCreateCaptchaRequest { Text = "test text" }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + Assert.That(result.Text, Is.EqualTo("test text")); + } + + [Test] + public void GetRequestToDomainUsesDefaultWidthAndHeightWhenNotProvided() + { + // Arrange + var request = new GetCreateCaptchaRequest { Text = "some text" }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Width, Is.EqualTo(Constants.DefaultCaptchaWidth)); + Assert.That(result.Height, Is.EqualTo(Constants.DefaultCaptchaHeight)); + } + } + + [Test] + public void GetRequestToDomainUsesProvidedWidthAndHeight() + { + // Arrange + var request = new GetCreateCaptchaRequest + { + Width = 500, + Height = 300, + Text = "another text" + }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Width, Is.EqualTo(500)); + Assert.That(result.Height, Is.EqualTo(300)); + } + } + + [Test] + public void GetRequestToDomainUsesDefaultFrequencyWhenDifficultyNotProvided() + { + // Arrange + var request = new GetCreateCaptchaRequest { Text = "default freq" }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + Assert.That(result.Frequency, Is.EqualTo(Constants.DefaultFrequency)); + } + + [Test] + public void GetRequestToDomainUsesCorrectFrequencyForDifficulty() + { + // Arrange + var easyRequest = new GetCreateCaptchaRequest + { + Difficulty = CaptchaDifficulty.Easy, + Text = "easy" + }; + + var mediumRequest = new GetCreateCaptchaRequest + { + Difficulty = CaptchaDifficulty.Medium, + Text = "medium" + }; + + var challengingRequest = new GetCreateCaptchaRequest + { + Difficulty = CaptchaDifficulty.Challenging, + Text = "challenging" + }; + + var hardRequest = new GetCreateCaptchaRequest + { + Difficulty = CaptchaDifficulty.Hard, + Text = "hard" + }; + + // Act + var easyResult = _requestToDomainMapper.ToDomain(easyRequest); + var mediumResult = _requestToDomainMapper.ToDomain(mediumRequest); + var challengingResult = _requestToDomainMapper.ToDomain(challengingRequest); + var hardResult = _requestToDomainMapper.ToDomain(hardRequest); + + // Assert + using (Assert.EnterMultipleScope()) + { + Assert.That(hardResult.Frequency, Is.EqualTo(20F), "Hard frequency"); + Assert.That(easyResult.Frequency, Is.EqualTo(300F), "Easy frequency"); + Assert.That(challengingResult.Frequency, Is.EqualTo(30F), "Challenging frequency"); + Assert.That(mediumResult.Frequency, Is.EqualTo(Constants.DefaultFrequency), "Medium frequency"); + } + } + + [Test] + public void GetRequestToDomainUsesDefaultFrequencyForUnrecognizedDifficulty() + { + // Arrange + var request = new GetCreateCaptchaRequest + { + Difficulty = (CaptchaDifficulty)999, + Text = "unknown difficulty" + }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + Assert.That(result.Frequency, Is.EqualTo(Constants.DefaultFrequency)); + } + + [Test] + public void GetRequestToDomainMapsMultipleRequestsCorrectly() + { + // Arrange + var requests = new List + { + new() + { + Text = "test1", + Width = 500, + Height = 200, + Difficulty = CaptchaDifficulty.Easy + }, + new() + { + Text = "test2", + Width = 600, + Height = 300, + Difficulty = CaptchaDifficulty.Hard + }, + }; + + // Act + var results = requests.Select(_requestToDomainMapper.ToDomain).ToList(); + + // Assert + using (Assert.EnterMultipleScope()) + { + Assert.That(results[0].Text, Is.EqualTo(requests[0].Text)); + Assert.That(results[0].Width, Is.EqualTo(requests[0].Width)); + Assert.That(results[0].Height, Is.EqualTo(requests[0].Height)); + Assert.That(results[0].Frequency, Is.EqualTo(600F)); + + Assert.That(results[1].Text, Is.EqualTo(requests[1].Text)); + Assert.That(results[1].Width, Is.EqualTo(requests[1].Width)); + Assert.That(results[1].Height, Is.EqualTo(requests[1].Height)); + Assert.That(results[1].Frequency, Is.EqualTo(80F)); + } + } + + [Test] + public void GetRequestToDomainThrowsWhenRequestIsNull() + { + // Arrange + GetCreateCaptchaRequest request = null; + + // Act & Assert + Assert.Throws(() => _requestToDomainMapper.ToDomain(request)); + } + + [Test] + public void GetRequestToDomainUsesDefaultPrimaryColorWhenNotProvided() + { + // Arrange + var request = new GetCreateCaptchaRequest + { + Text = "text" + }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + using (Assert.EnterMultipleScope()) + { + Assert.That(result.PrimaryColor, Is.EqualTo(Constants.DefaultPrimaryColor)); + Assert.That(result.SecondaryColor, Is.EqualTo(Constants.DefaultSecondaryColor)); + } + } + + [Test] + public void GetRequestToDomainHandlesNullText() + { + // Arrange + var request = new GetCreateCaptchaRequest + { + Text = null, + Width = 100, + Height = 50 + }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + using (Assert.EnterMultipleScope()) + { + // Assert + Assert.That(result.Text, Is.Null); + Assert.That(result.Width, Is.EqualTo(100)); + Assert.That(result.Height, Is.EqualTo(50)); + } + } + + [Test] + public void GetRequestToDomainHandlesExcessivelyLargeWidthAndHeight() + { + // Arrange + var request = new GetCreateCaptchaRequest + { + Text = "Large dimensions", + Width = int.MaxValue, + Height = int.MaxValue + }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Width, Is.EqualTo(int.MaxValue)); + Assert.That(result.Height, Is.EqualTo(int.MaxValue)); + } + } + + [Test] + public void GetRequestToDomainPreservesWhitespaceInText() + { + // Arrange + var originalText = " Leading, internal and trailing whitespace "; + var request = new GetCreateCaptchaRequest + { + Text = originalText + }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + Assert.That(result.Text, Is.EqualTo(originalText)); + } + + + [Test] + public void GetRequestToDomainPreservesUnicodeText() + { + // Arrange + var unicodeString = "Captcha 🚀 Test – 你好"; + var request = new GetCreateCaptchaRequest + { + Text = unicodeString + }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + Assert.That(result.Text, Is.EqualTo(unicodeString)); + } + + [Test] + public void GetRequestToDomainAllowsEmptyText() + { + // Arrange + var request = new GetCreateCaptchaRequest + { + Text = string.Empty + }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + Assert.That(result.Text, Is.EqualTo(string.Empty)); + } + + [Test] + public void GetRequestToDomainCanHandleExtremelyLongText() + { + // Arrange + var longText = new string('x', 10_000); + var request = new GetCreateCaptchaRequest + { + Text = longText + }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + Assert.That(result.Text, Has.Length.EqualTo(10_000)); + Assert.That(result.Text, Is.EqualTo(longText)); + } + + [Test] + public void GetRequestToDomainUsesDefaultHeightWhenOnlyWidthProvided() + { + // Arrange + var request = new GetCreateCaptchaRequest { Text = "w-only", Width = 321 }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Width, Is.EqualTo(321)); + Assert.That(result.Height, Is.EqualTo(Constants.DefaultCaptchaHeight)); + } + } + + [Test] + public void GetRequestToDomainUsesDefaultWidthWhenOnlyHeightProvided() + { + // Arrange + var request = new GetCreateCaptchaRequest { Text = "h-only", Height = 654 }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Width, Is.EqualTo(Constants.DefaultCaptchaWidth)); + Assert.That(result.Height, Is.EqualTo(654)); + } + } + + [Test] + public void GetRequestToDomainUsesProvidedThemeColorsWhenValidHex() + { + // Arrange + var request = new GetCreateCaptchaRequest + { + Text = "colors", + Theme = new() + { + PrimaryColor = "#112233", + SecondaryColor = "#AABBCC" + } + }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + using (Assert.EnterMultipleScope()) + { + Assert.That(result.PrimaryColor, Is.EqualTo(SkiaSharp.SKColor.Parse("#112233"))); + Assert.That(result.SecondaryColor, Is.EqualTo(SkiaSharp.SKColor.Parse("#AABBCC"))); + } + } + + [Test] + public void GetRequestToDomainParsesHexCaseInsensitively() + { + // Arrange + var request = new GetCreateCaptchaRequest + { + Text = "test", + Theme = new() + { + PrimaryColor = "#a1b2c3", + SecondaryColor = "#80FF0000" + } + }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + var expectedPrimary = SkiaSharp.SKColor.Parse("#A1B2C3"); + var expectedSecondary = SkiaSharp.SKColor.Parse("#80FF0000"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.PrimaryColor, Is.EqualTo(expectedPrimary)); + Assert.That(result.SecondaryColor, Is.EqualTo(expectedSecondary)); + Assert.That(result.SecondaryColor.Alpha, Is.EqualTo(expectedSecondary.Alpha)); + Assert.That(result.SecondaryColor.Red, Is.EqualTo(expectedSecondary.Red)); + Assert.That(result.SecondaryColor.Green, Is.EqualTo(expectedSecondary.Green)); + Assert.That(result.SecondaryColor.Blue, Is.EqualTo(expectedSecondary.Blue)); + } + } + + [Test] + public void GetRequestToDomainUsesDefaultColorsWhenThemeProvidedButEmptyOrWhitespace() + { + // Arrange + var request = new GetCreateCaptchaRequest + { + Text = "empty theme", + Theme = new() + { + PrimaryColor = " ", + SecondaryColor = "" + } + }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + using (Assert.EnterMultipleScope()) + { + Assert.That(result.PrimaryColor, Is.EqualTo(Constants.DefaultPrimaryColor)); + Assert.That(result.SecondaryColor, Is.EqualTo(Constants.DefaultSecondaryColor)); + } + } + + [Test] + public void GetRequestToDomainUsesDefaultSecondaryColorWhenOnlyPrimaryProvided() + { + // Arrange + var request = new GetCreateCaptchaRequest + { + Text = "only primary", + Theme = new() + { + PrimaryColor = "#010203", + SecondaryColor = null + } + }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + using (Assert.EnterMultipleScope()) + { + Assert.That(result.PrimaryColor, Is.EqualTo(SkiaSharp.SKColor.Parse("#010203"))); + Assert.That(result.SecondaryColor, Is.EqualTo(Constants.DefaultSecondaryColor)); + } + } + + [Test] + public void GetRequestToDomainUsesDefaultPrimaryColorWhenOnlySecondaryProvided() + { + // Arrange + var request = new GetCreateCaptchaRequest + { + Text = "only secondary", + Theme = new() + { + PrimaryColor = null, + SecondaryColor = "#0A0B0C" + } + }; + + // Act + var result = _requestToDomainMapper.ToDomain(request); + + // Assert + using (Assert.EnterMultipleScope()) + { + Assert.That(result.PrimaryColor, Is.EqualTo(Constants.DefaultPrimaryColor)); + Assert.That(result.SecondaryColor, Is.EqualTo(SkiaSharp.SKColor.Parse("#0A0B0C"))); + } + } }