Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -259,6 +259,10 @@ The runner currently supports these assertion keys:
- `startsWith`
- `endsWith`
- `notEmpty`
- `greaterThan`
- `greaterThanOrEqual`
- `lessThan`
- `lessThanOrEqual`
- `minCount`
- `maxCount`
- `count`
Expand Down Expand Up @@ -290,6 +294,9 @@ assertions:
type: array
minCount: 1

- field: data.totalRowsCount
greaterThan: 0

- field: data.accounts
contains:
status: Active
Expand Down Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions src/ApiTestRunner.App/wwwroot/curl-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions src/ApiTestRunner.App/wwwroot/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions src/ApiTestRunner.Core/Models/TestSuiteDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object?> Contains { get; init; } = new(StringComparer.OrdinalIgnoreCase);
}
4 changes: 4 additions & 0 deletions src/ApiTestRunner.Core/Services/ApiTestExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,10 @@ private IReadOnlyList<AssertionDefinition> 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<string, object?>()
: assertion.Contains.ToDictionary(
Expand Down
164 changes: 164 additions & 0 deletions src/ApiTestRunner.Core/Services/AssertionEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand Down Expand Up @@ -338,6 +390,45 @@ JsonValue value when value.TryGetValue<string>(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<decimal, decimal, bool> comparator,
string comparisonText,
ICollection<AssertionResult> 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<string, object?> expectedFields)
{
if (node is not JsonArray array)
Expand Down Expand Up @@ -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<decimal>(out var decimalValue):
result = decimalValue;
return true;
case JsonValue value when value.TryGetValue<double>(out var doubleValue):
result = Convert.ToDecimal(doubleValue, CultureInfo.InvariantCulture);
return true;
case JsonValue value when value.TryGetValue<long>(out var longValue):
result = longValue;
return true;
case JsonValue value when value.TryGetValue<int>(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);
}
}
49 changes: 49 additions & 0 deletions tests/ApiTestRunner.Core.Tests/AssertionEvaluatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
836548df8afe09565129f3173b86cff614d1d691aea07ae448f6a0c8fbe5591e
de71641cb98584abf14d1360f8f53b257d514e5941bd13915040f074cf053cad
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"documents":{"D:\\Projects\\Research\\EndpointTestRunner\\*":"https://raw.githubusercontent.com/javaChip56/EndpointTestRunner/63041666a7071e4da20206b2c691c0ad24079ce6/*"}}
{"documents":{"D:\\Projects\\Research\\EndpointTestRunner\\*":"https://raw.githubusercontent.com/javaChip56/EndpointTestRunner/58a923a3f9fdbd3615286582ba1c87de0ba34888/*"}}
Binary file not shown.
Binary file not shown.
Loading