diff --git a/README.md b/README.md index a6b108c..451e822 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Current first-version scope: - best support is for common `curl`, `-X`, `-H`, `--data`, `--data-raw`, `--data-binary`, and `--url` forms - the page now uses one main `Analyze and Generate` action instead of separate request-analysis and response-parse steps - generated YAML is shown as preview text, not written directly to disk -- the assertion builder currently targets the most common field assertions: `equals`, `notEquals`, `type`, `containsText`, `startsWith`, `endsWith`, `notEmpty`, `minCount`, `maxCount`, and `count` +- the assertion builder currently targets the most common field assertions: `equals`, `notEquals`, `type`, `containsText`, `startsWith`, `endsWith`, `notEmpty`, `greaterThan`, `greaterThanOrEqual`, `lessThan`, `lessThanOrEqual`, `minCount`, `maxCount`, and `count` - missing YAML warnings are surfaced in the cURL import UI, but malformed YAML content still needs to be fixed before the dashboard runner can execute the suite ## CI/CD @@ -259,6 +259,10 @@ The runner currently supports these assertion keys: - `startsWith` - `endsWith` - `notEmpty` +- `greaterThan` +- `greaterThanOrEqual` +- `lessThan` +- `lessThanOrEqual` - `minCount` - `maxCount` - `count` @@ -290,6 +294,9 @@ assertions: type: array minCount: 1 + - field: data.totalRowsCount + greaterThan: 0 + - field: data.accounts contains: status: Active @@ -360,7 +367,7 @@ Notes: - A token can occupy the whole value or be embedded inside a larger string. - Environment variables can reference other environment variables. - `config:` reads from application configuration, so `config:Variables.DefaultCustomerId` maps to `Variables:DefaultCustomerId`. -- Assertions support tokens in `field`, `equals`, `notEquals`, `type`, `containsText`, `startsWith`, `endsWith`, `contains`, `notEmpty`, `minCount`, `maxCount`, and `count`. +- Assertions support tokens in `field`, `equals`, `notEquals`, `type`, `containsText`, `startsWith`, `endsWith`, `contains`, `notEmpty`, `greaterThan`, `greaterThanOrEqual`, `lessThan`, `lessThanOrEqual`, `minCount`, `maxCount`, and `count`. ## Recommended split layout diff --git a/src/ApiTestRunner.App/wwwroot/curl-import.js b/src/ApiTestRunner.App/wwwroot/curl-import.js index 34abe24..6f50218 100644 --- a/src/ApiTestRunner.App/wwwroot/curl-import.js +++ b/src/ApiTestRunner.App/wwwroot/curl-import.js @@ -31,6 +31,10 @@ const assertionRuleDefinitions = { { label: "false", value: false } ] }, + greaterThan: { label: "greaterThan", valueMode: "number" }, + greaterThanOrEqual: { label: "greaterThanOrEqual", valueMode: "number" }, + lessThan: { label: "lessThan", valueMode: "number" }, + lessThanOrEqual: { label: "lessThanOrEqual", valueMode: "number" }, minCount: { label: "minCount", valueMode: "number" }, maxCount: { label: "maxCount", valueMode: "number" }, count: { label: "count", valueMode: "number" } @@ -307,6 +311,15 @@ function getRulesForFieldType(fieldType) { case "object": return ["type", "notEmpty"]; case "number": + return [ + "equals", + "notEquals", + "type", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual" + ]; case "boolean": return ["equals", "notEquals", "type"]; default: diff --git a/src/ApiTestRunner.App/wwwroot/styles.css b/src/ApiTestRunner.App/wwwroot/styles.css index 6834045..6a13039 100644 --- a/src/ApiTestRunner.App/wwwroot/styles.css +++ b/src/ApiTestRunner.App/wwwroot/styles.css @@ -113,6 +113,25 @@ body.app-shell { backdrop-filter: blur(10px); } +.selection-panel, +.results-panel { + position: relative; + overflow: hidden; +} + +.selection-panel::before, +.results-panel::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: linear-gradient(180deg, var(--app-accent), #164e63); + border-radius: 1rem 0 0 1rem; + pointer-events: none; +} + .metric-card { overflow: hidden; border-width: 0; diff --git a/src/ApiTestRunner.Core/Models/TestSuiteDefinition.cs b/src/ApiTestRunner.Core/Models/TestSuiteDefinition.cs index 44a94d4..f3a7d5e 100644 --- a/src/ApiTestRunner.Core/Models/TestSuiteDefinition.cs +++ b/src/ApiTestRunner.Core/Models/TestSuiteDefinition.cs @@ -80,5 +80,13 @@ public sealed record class AssertionDefinition public object? Count { get; init; } + public object? GreaterThan { get; init; } + + public object? GreaterThanOrEqual { get; init; } + + public object? LessThan { get; init; } + + public object? LessThanOrEqual { get; init; } + public Dictionary Contains { get; init; } = new(StringComparer.OrdinalIgnoreCase); } diff --git a/src/ApiTestRunner.Core/Services/ApiTestExecutor.cs b/src/ApiTestRunner.Core/Services/ApiTestExecutor.cs index da8c871..329b425 100644 --- a/src/ApiTestRunner.Core/Services/ApiTestExecutor.cs +++ b/src/ApiTestRunner.Core/Services/ApiTestExecutor.cs @@ -278,6 +278,10 @@ private IReadOnlyList ResolveAssertions( MinCount = _variableResolver.ResolveValue(assertion.MinCount, environment), MaxCount = _variableResolver.ResolveValue(assertion.MaxCount, environment), Count = _variableResolver.ResolveValue(assertion.Count, environment), + GreaterThan = _variableResolver.ResolveValue(assertion.GreaterThan, environment), + GreaterThanOrEqual = _variableResolver.ResolveValue(assertion.GreaterThanOrEqual, environment), + LessThan = _variableResolver.ResolveValue(assertion.LessThan, environment), + LessThanOrEqual = _variableResolver.ResolveValue(assertion.LessThanOrEqual, environment), Contains = assertion.Contains.Count == 0 ? new Dictionary() : assertion.Contains.ToDictionary( diff --git a/src/ApiTestRunner.Core/Services/AssertionEvaluator.cs b/src/ApiTestRunner.Core/Services/AssertionEvaluator.cs index 60b37d5..59b6457 100644 --- a/src/ApiTestRunner.Core/Services/AssertionEvaluator.cs +++ b/src/ApiTestRunner.Core/Services/AssertionEvaluator.cs @@ -261,6 +261,58 @@ private static void Evaluate( } } + if (assertion.GreaterThan is not null) + { + rulesAdded++; + EvaluateNumericComparison( + assertion.Field, + "greaterThan", + assertion.GreaterThan, + actualNode, + (actual, expected) => actual > expected, + "greater than", + results); + } + + if (assertion.GreaterThanOrEqual is not null) + { + rulesAdded++; + EvaluateNumericComparison( + assertion.Field, + "greaterThanOrEqual", + assertion.GreaterThanOrEqual, + actualNode, + (actual, expected) => actual >= expected, + "greater than or equal to", + results); + } + + if (assertion.LessThan is not null) + { + rulesAdded++; + EvaluateNumericComparison( + assertion.Field, + "lessThan", + assertion.LessThan, + actualNode, + (actual, expected) => actual < expected, + "less than", + results); + } + + if (assertion.LessThanOrEqual is not null) + { + rulesAdded++; + EvaluateNumericComparison( + assertion.Field, + "lessThanOrEqual", + assertion.LessThanOrEqual, + actualNode, + (actual, expected) => actual <= expected, + "less than or equal to", + results); + } + if (assertion.Contains.Count > 0) { rulesAdded++; @@ -338,6 +390,45 @@ JsonValue value when value.TryGetValue(out var text) => !string.IsNullOr return node is JsonArray array ? array.Count : null; } + private static void EvaluateNumericComparison( + string field, + string rule, + object? expectedValue, + JsonNode? actualNode, + Func comparator, + string comparisonText, + ICollection results) + { + if (!TryConvertToDecimal(expectedValue, out var expectedNumber)) + { + results.Add(CreateResult( + field, + rule, + false, + $"{rule} must resolve to a number.")); + return; + } + + if (!TryGetNumericValue(actualNode, out var actualNumber)) + { + results.Add(CreateResult( + field, + rule, + false, + "Field was not a number.")); + return; + } + + var success = comparator(actualNumber, expectedNumber); + results.Add(CreateResult( + field, + rule, + success, + success + ? $"Value was {comparisonText} {FormatDecimal(expectedNumber)}." + : $"Value was {FormatDecimal(actualNumber)}, expected {comparisonText} {FormatDecimal(expectedNumber)}.")); + } + private static bool ArrayContainsMatch(JsonNode? node, IReadOnlyDictionary expectedFields) { if (node is not JsonArray array) @@ -433,4 +524,77 @@ private static bool TryConvertToInteger(object? value, out int result) return false; } } + + private static bool TryGetNumericValue(JsonNode? node, out decimal result) + { + switch (node) + { + case JsonValue value when value.TryGetValue(out var decimalValue): + result = decimalValue; + return true; + case JsonValue value when value.TryGetValue(out var doubleValue): + result = Convert.ToDecimal(doubleValue, CultureInfo.InvariantCulture); + return true; + case JsonValue value when value.TryGetValue(out var longValue): + result = longValue; + return true; + case JsonValue value when value.TryGetValue(out var intValue): + result = intValue; + return true; + default: + result = 0; + return false; + } + } + + private static bool TryConvertToDecimal(object? value, out decimal result) + { + switch (value) + { + case sbyte number: + result = number; + return true; + case byte number: + result = number; + return true; + case short number: + result = number; + return true; + case ushort number: + result = number; + return true; + case int number: + result = number; + return true; + case uint number: + result = number; + return true; + case long number: + result = number; + return true; + case ulong number: + result = number; + return true; + case float number: + result = Convert.ToDecimal(number, CultureInfo.InvariantCulture); + return true; + case double number: + result = Convert.ToDecimal(number, CultureInfo.InvariantCulture); + return true; + case decimal number: + result = number; + return true; + case string text when decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed): + result = parsed; + return true; + default: + result = 0; + return false; + } + } + + private static string FormatDecimal(decimal value) + { + return value.ToString("0.############################", CultureInfo.InvariantCulture); + } } diff --git a/tests/ApiTestRunner.Core.Tests/AssertionEvaluatorTests.cs b/tests/ApiTestRunner.Core.Tests/AssertionEvaluatorTests.cs index 068c099..aa35dde 100644 --- a/tests/ApiTestRunner.Core.Tests/AssertionEvaluatorTests.cs +++ b/tests/ApiTestRunner.Core.Tests/AssertionEvaluatorTests.cs @@ -127,4 +127,53 @@ public void EvaluateAll_AcceptsYamlSizedIntegralTypesForCountAssertions() Assert.NotEmpty(results); Assert.All(results, result => Assert.True(result.IsSuccess, result.Message)); } + + [Fact] + public void EvaluateAll_SupportsNumericComparisonAssertions() + { + var response = JsonNode.Parse(""" + { + "statusCode": 1, + "data": { + "totalRowsCount": 149, + "profitPercentage": -74.03 + } + } + """); + + var assertions = new[] + { + new AssertionDefinition { Field = "data.totalRowsCount", GreaterThan = 0 }, + new AssertionDefinition { Field = "data.totalRowsCount", GreaterThanOrEqual = 149 }, + new AssertionDefinition { Field = "data.totalRowsCount", LessThan = 200 }, + new AssertionDefinition { Field = "data.profitPercentage", LessThanOrEqual = -74.03m } + }; + + var results = _evaluator.EvaluateAll(assertions, response); + + Assert.NotEmpty(results); + Assert.All(results, result => Assert.True(result.IsSuccess, result.Message)); + } + + [Fact] + public void EvaluateAll_FailsNumericComparisonAssertionsWhenFieldIsNotNumeric() + { + var response = JsonNode.Parse("""{ "message": "Account list details found." }"""); + + var assertions = new[] + { + new AssertionDefinition + { + Field = "message", + GreaterThan = 0 + } + }; + + var results = _evaluator.EvaluateAll(assertions, response); + + var result = Assert.Single(results); + Assert.False(result.IsSuccess); + Assert.Equal("greaterThan", result.Rule); + Assert.Contains("not a number", result.Message); + } } diff --git a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.dll b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.dll index 0b287e1..e849a86 100644 Binary files a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.dll and b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.dll differ diff --git a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.pdb b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.pdb index a95add8..cefe15e 100644 Binary files a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.pdb and b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.Tests.pdb differ diff --git a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.dll b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.dll index 7d60585..71e1a34 100644 Binary files a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.dll and b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.dll differ diff --git a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.pdb b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.pdb index 293c635..eac87dc 100644 Binary files a/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.pdb and b/tests/ApiTestRunner.Core.Tests/bin/Release/net8.0/ApiTestRunner.Core.pdb differ diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfo.cs b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfo.cs index 5af6ebe..cf26e89 100644 --- a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfo.cs +++ b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfo.cs @@ -13,7 +13,7 @@ [assembly: System.Reflection.AssemblyCompanyAttribute("ApiTestRunner.Core.Tests")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+63041666a7071e4da20206b2c691c0ad24079ce6")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+58a923a3f9fdbd3615286582ba1c87de0ba34888")] [assembly: System.Reflection.AssemblyProductAttribute("ApiTestRunner.Core.Tests")] [assembly: System.Reflection.AssemblyTitleAttribute("ApiTestRunner.Core.Tests")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfoInputs.cache b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfoInputs.cache index 2631508..b311a7f 100644 --- a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfoInputs.cache +++ b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.AssemblyInfoInputs.cache @@ -1 +1 @@ -836548df8afe09565129f3173b86cff614d1d691aea07ae448f6a0c8fbe5591e +de71641cb98584abf14d1360f8f53b257d514e5941bd13915040f074cf053cad diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.csproj.AssemblyReference.cache b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.csproj.AssemblyReference.cache index fc9d4da..e74eeab 100644 Binary files a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.csproj.AssemblyReference.cache and b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.csproj.AssemblyReference.cache differ diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.dll b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.dll index 0b287e1..e849a86 100644 Binary files a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.dll and b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.dll differ diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.pdb b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.pdb index a95add8..cefe15e 100644 Binary files a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.pdb and b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.pdb differ diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.sourcelink.json b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.sourcelink.json index cb71235..34ec87a 100644 --- a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.sourcelink.json +++ b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ApiTestRunner.Core.Tests.sourcelink.json @@ -1 +1 @@ -{"documents":{"D:\\Projects\\Research\\EndpointTestRunner\\*":"https://raw.githubusercontent.com/javaChip56/EndpointTestRunner/63041666a7071e4da20206b2c691c0ad24079ce6/*"}} \ No newline at end of file +{"documents":{"D:\\Projects\\Research\\EndpointTestRunner\\*":"https://raw.githubusercontent.com/javaChip56/EndpointTestRunner/58a923a3f9fdbd3615286582ba1c87de0ba34888/*"}} \ No newline at end of file diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ref/ApiTestRunner.Core.Tests.dll b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ref/ApiTestRunner.Core.Tests.dll index 5cf66d4..14d9af8 100644 Binary files a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ref/ApiTestRunner.Core.Tests.dll and b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/ref/ApiTestRunner.Core.Tests.dll differ diff --git a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/refint/ApiTestRunner.Core.Tests.dll b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/refint/ApiTestRunner.Core.Tests.dll index 5cf66d4..14d9af8 100644 Binary files a/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/refint/ApiTestRunner.Core.Tests.dll and b/tests/ApiTestRunner.Core.Tests/obj/Release/net8.0/refint/ApiTestRunner.Core.Tests.dll differ