feat(inbound-api): nested Object/List extended-type validation (#13)
Object/List parameters and return values were shape-validated only (object vs
array), with no field-level/nested type checks — type-wrong nested data passed
inbound validation and failed only at script runtime. Add recursive type
validation (declared Object field types, List element type, scalars at any depth)
with path-qualified errors, symmetric across ParameterValidator and ReturnValueValidator.
Both validators now parse the canonical JSON Schema definition format (the
Central UI / MigrateParametersToJsonSchema output) via a shared recursive engine,
Commons.Types.InboundApi.InboundApiSchema, instead of the legacy flat
[{name,type}] array which they could not even deserialize from migrated rows.
The legacy flat-array form is still accepted on read for transition safety.
Undeclared fields are rejected at every level (consistent with the existing
top-level unexpected-parameter rejection); a present-but-null value satisfies
any type, only absence of a required field is an error.
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-014: tests for return-value validation against a method's
|
||||
/// <c>ReturnDefinition</c>. Previously the script's return value was serialized
|
||||
/// verbatim with no checking against the declared return structure.
|
||||
/// InboundAPI-014 / InboundAPI-M2.6: tests for return-value validation against a
|
||||
/// method's <c>ReturnDefinition</c>. Mirrors <see cref="ParameterValidatorTests"/>
|
||||
/// (shared recursive engine) — RECURSIVE nested Object / List-element type
|
||||
/// validation with path-qualified errors.
|
||||
///
|
||||
/// <para>
|
||||
/// Definitions are expressed as JSON Schema (the canonical persisted format);
|
||||
/// the legacy flat-array form is still accepted (final region).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class ReturnValueValidatorTests
|
||||
{
|
||||
// --- No definition → no validation (backward compatible) ---
|
||||
// ── No definition → no validation (backward compatible) ───────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
@@ -26,12 +32,17 @@ public class ReturnValueValidatorTests
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
// --- Happy path: result matches the declared field shape ---
|
||||
// ── Happy path: result matches the declared object shape ──────────────────
|
||||
|
||||
[Fact]
|
||||
public void ResultMatchingDefinition_IsValid()
|
||||
{
|
||||
const string def = """[{"name":"siteName","type":"String"},{"name":"totalUnits","type":"Integer"}]""";
|
||||
const string def = """
|
||||
{"type":"object","properties":{
|
||||
"siteName":{"type":"string"},
|
||||
"totalUnits":{"type":"integer"}
|
||||
},"required":["siteName","totalUnits"]}
|
||||
""";
|
||||
const string json = """{"siteName":"Site Alpha","totalUnits":14250}""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, def);
|
||||
@@ -40,22 +51,31 @@ public class ReturnValueValidatorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResultWithListField_ShapeChecked_IsValid()
|
||||
public void ResultWithListOfScalars_TypeChecked_IsValid()
|
||||
{
|
||||
const string def = """[{"name":"lines","type":"List"}]""";
|
||||
const string json = """{"lines":[{"lineName":"Line-1","units":8200}]}""";
|
||||
const string def = """
|
||||
{"type":"object","properties":{
|
||||
"codes":{"type":"array","items":{"type":"integer"}}
|
||||
}}
|
||||
""";
|
||||
const string json = """{"codes":[1,2,3]}""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, def);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
// --- Mismatches must be reported ---
|
||||
// ── Scalar / shape mismatches must be reported ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ResultMissingDeclaredField_IsInvalid()
|
||||
{
|
||||
const string def = """[{"name":"siteName","type":"String"},{"name":"totalUnits","type":"Integer"}]""";
|
||||
const string def = """
|
||||
{"type":"object","properties":{
|
||||
"siteName":{"type":"string"},
|
||||
"totalUnits":{"type":"integer"}
|
||||
},"required":["siteName","totalUnits"]}
|
||||
""";
|
||||
const string json = """{"siteName":"Site Alpha"}""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, def);
|
||||
@@ -67,7 +87,7 @@ public class ReturnValueValidatorTests
|
||||
[Fact]
|
||||
public void ResultFieldWrongType_IsInvalid()
|
||||
{
|
||||
const string def = """[{"name":"totalUnits","type":"Integer"}]""";
|
||||
const string def = """{"type":"object","properties":{"totalUnits":{"type":"integer"}},"required":["totalUnits"]}""";
|
||||
const string json = """{"totalUnits":"not-a-number"}""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, def);
|
||||
@@ -79,7 +99,7 @@ public class ReturnValueValidatorTests
|
||||
[Fact]
|
||||
public void NullResultWhenStructureRequired_IsInvalid()
|
||||
{
|
||||
const string def = """[{"name":"siteName","type":"String"}]""";
|
||||
const string def = """{"type":"object","properties":{"siteName":{"type":"string"}},"required":["siteName"]}""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(null, def);
|
||||
|
||||
@@ -89,7 +109,7 @@ public class ReturnValueValidatorTests
|
||||
[Fact]
|
||||
public void NonObjectResultWhenStructureRequired_IsInvalid()
|
||||
{
|
||||
const string def = """[{"name":"siteName","type":"String"}]""";
|
||||
const string def = """{"type":"object","properties":{"siteName":{"type":"string"}},"required":["siteName"]}""";
|
||||
|
||||
var result = ReturnValueValidator.Validate("42", def);
|
||||
|
||||
@@ -99,7 +119,7 @@ public class ReturnValueValidatorTests
|
||||
[Fact]
|
||||
public void ListFieldGivenNonArray_IsInvalid()
|
||||
{
|
||||
const string def = """[{"name":"lines","type":"List"}]""";
|
||||
const string def = """{"type":"object","properties":{"lines":{"type":"array","items":{"type":"object"}}}}""";
|
||||
const string json = """{"lines":"not-a-list"}""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, def);
|
||||
@@ -115,4 +135,173 @@ public class ReturnValueValidatorTests
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
// ── Nested validation: the M2.6 core (production-report shape) ─────────────
|
||||
|
||||
private const string ReportDef = """
|
||||
{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"siteName":{"type":"string"},
|
||||
"totalUnits":{"type":"integer"},
|
||||
"lines":{
|
||||
"type":"array",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"lineName":{"type":"string"},
|
||||
"units":{"type":"integer"},
|
||||
"efficiency":{"type":"number"}
|
||||
},
|
||||
"required":["lineName","units"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["siteName","totalUnits","lines"]
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void ValidNestedReturn_Passes()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"siteName":"Site Alpha",
|
||||
"totalUnits":14250,
|
||||
"lines":[
|
||||
{"lineName":"Line-1","units":8200,"efficiency":92.5},
|
||||
{"lineName":"Line-2","units":6050,"efficiency":88.1}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, ReportDef);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongScalarInsideListElement_IsInvalid_WithElementIndexInPath()
|
||||
{
|
||||
// lines[1].units declared integer, given a string.
|
||||
const string json = """
|
||||
{
|
||||
"siteName":"Site Alpha",
|
||||
"totalUnits":14250,
|
||||
"lines":[
|
||||
{"lineName":"Line-1","units":8200},
|
||||
{"lineName":"Line-2","units":"lots"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, ReportDef);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("'lines[1].units'", result.ErrorMessage);
|
||||
Assert.Contains("Integer", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongListElementType_IsInvalid_WithElementIndexInPath()
|
||||
{
|
||||
// lines[0] declared object, given a scalar.
|
||||
const string json = """
|
||||
{
|
||||
"siteName":"Site Alpha",
|
||||
"totalUnits":14250,
|
||||
"lines":[ 7 ]
|
||||
}
|
||||
""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, ReportDef);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("'lines[0]'", result.ErrorMessage);
|
||||
Assert.Contains("Object", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingRequiredNestedField_IsInvalid_PathQualified()
|
||||
{
|
||||
// lines[0].units is required but absent.
|
||||
const string json = """
|
||||
{
|
||||
"siteName":"Site Alpha",
|
||||
"totalUnits":14250,
|
||||
"lines":[ {"lineName":"Line-1"} ]
|
||||
}
|
||||
""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, ReportDef);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("missing required field", result.ErrorMessage);
|
||||
Assert.Contains("'lines[0].units'", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UndeclaredNestedField_IsInvalid_PathQualified()
|
||||
{
|
||||
// lines[0].bogus is not declared on the line-item schema.
|
||||
const string json = """
|
||||
{
|
||||
"siteName":"Site Alpha",
|
||||
"totalUnits":14250,
|
||||
"lines":[ {"lineName":"Line-1","units":1,"bogus":true} ]
|
||||
}
|
||||
""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, ReportDef);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("'lines[0].bogus'", result.ErrorMessage);
|
||||
Assert.Contains("not a declared field", result.ErrorMessage);
|
||||
}
|
||||
|
||||
// ── Empty / null edge cases ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void EmptyListAgainstTypedElement_Passes()
|
||||
{
|
||||
const string json = """{"siteName":"S","totalUnits":0,"lines":[]}""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, ReportDef);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyObjectSchema_AnythingIsValid()
|
||||
{
|
||||
const string def = """{"type":"object","properties":{}}""";
|
||||
|
||||
var result = ReturnValueValidator.Validate("""{"whatever":1}""", def);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullOptionalNestedScalar_Passes()
|
||||
{
|
||||
// lines[0].efficiency is optional; explicit null is accepted.
|
||||
const string json = """
|
||||
{
|
||||
"siteName":"S",
|
||||
"totalUnits":1,
|
||||
"lines":[ {"lineName":"L1","units":1,"efficiency":null} ]
|
||||
}
|
||||
""";
|
||||
|
||||
var result = ReturnValueValidator.Validate(json, ReportDef);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
// ── Legacy flat-array backward-compat ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void LegacyFlatArrayDefinition_StillAccepted()
|
||||
{
|
||||
const string def = """[{"name":"siteName","type":"String"},{"name":"totalUnits","type":"Integer"}]""";
|
||||
|
||||
var ok = ReturnValueValidator.Validate("""{"siteName":"A","totalUnits":1}""", def);
|
||||
Assert.True(ok.IsValid);
|
||||
|
||||
var bad = ReturnValueValidator.Validate("""{"siteName":"A","totalUnits":"x"}""", def);
|
||||
Assert.False(bad.IsValid);
|
||||
Assert.Contains("totalUnits", bad.ErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user