using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// /// WP-2 / InboundAPI-M2.6: tests for parameter validation — type checking, /// required fields, the extended type system, and RECURSIVE (nested Object / /// List element) type validation with path-qualified errors. /// /// /// Definitions are expressed as JSON Schema (the canonical persisted format /// produced by the Central UI / migration). The validator also accepts the /// legacy flat-array form; that backward-compat path is covered by the final /// region. /// /// public class ParameterValidatorTests { private static JsonElement Body(string json) { using var doc = JsonDocument.Parse(json); return doc.RootElement.Clone(); } // ── No / empty definitions ──────────────────────────────────────────────── [Fact] public void NoDefinitions_NoBody_ReturnsValid() { var result = ParameterValidator.Validate(null, null); Assert.True(result.IsValid); Assert.Empty(result.Parameters); } [Fact] public void EmptyObjectSchema_ReturnsValid() { var result = ParameterValidator.Validate(null, """{"type":"object","properties":{}}"""); Assert.True(result.IsValid); } [Fact] public void EmptyLegacyArray_ReturnsValid() { var result = ParameterValidator.Validate(null, "[]"); Assert.True(result.IsValid); } // ── Required / body shape ────────────────────────────────────────────────── [Fact] public void RequiredParameterMissing_ReturnsInvalid() { const string def = """{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"]}"""; var result = ParameterValidator.Validate(null, def); Assert.False(result.IsValid); Assert.Contains("Missing required parameter", result.ErrorMessage); } [Fact] public void BodyNotObject_ReturnsInvalid() { const string def = """{"type":"object","properties":{"value":{"type":"string"}},"required":["value"]}"""; var result = ParameterValidator.Validate(Body("\"just a string\""), def); Assert.False(result.IsValid); Assert.Contains("must be a JSON object", result.ErrorMessage); } [Fact] public void OptionalParameter_MissingBody_ReturnsValid() { const string def = """{"type":"object","properties":{"optional":{"type":"string"}}}"""; var result = ParameterValidator.Validate(null, def); Assert.True(result.IsValid); } // ── Scalar coercion ──────────────────────────────────────────────────────── [Theory] [InlineData("boolean", "true", true)] [InlineData("integer", "42", (long)42)] [InlineData("number", "3.14", 3.14)] [InlineData("string", "\"hello\"", "hello")] public void ValidTypeCoercion_Succeeds(string type, string jsonValue, object expected) { var def = "{\"type\":\"object\",\"properties\":{\"val\":{\"type\":\"" + type + "\"}},\"required\":[\"val\"]}"; var result = ParameterValidator.Validate(Body($"{{\"val\": {jsonValue}}}"), def); Assert.True(result.IsValid); Assert.Equal(expected, result.Parameters["val"]); } [Fact] public void WrongScalarType_ReturnsInvalid() { const string def = """{"type":"object","properties":{"count":{"type":"integer"}},"required":["count"]}"""; var result = ParameterValidator.Validate(Body("{\"count\": \"not a number\"}"), def); Assert.False(result.IsValid); Assert.Contains("'count'", result.ErrorMessage); Assert.Contains("Integer", result.ErrorMessage); } [Fact] public void UnknownType_ReturnsInvalid() { const string def = """{"type":"object","properties":{"val":{"type":"customtype"}},"required":["val"]}"""; var result = ParameterValidator.Validate(Body("{\"val\": \"test\"}"), def); Assert.False(result.IsValid); Assert.Contains("unknown declared type", result.ErrorMessage); } // ── Object / List shape + materialization ────────────────────────────────── [Fact] public void ObjectType_NoDeclaredFields_ShapeOnly_Materialized() { const string def = """{"type":"object","properties":{"data":{"type":"object"}},"required":["data"]}"""; var result = ParameterValidator.Validate(Body("{\"data\": {\"key\": \"value\"}}"), def); Assert.True(result.IsValid); Assert.IsType>(result.Parameters["data"]); } [Fact] public void ListType_NoDeclaredElement_ShapeOnly_Materialized() { const string def = """{"type":"object","properties":{"items":{"type":"array"}},"required":["items"]}"""; var result = ParameterValidator.Validate(Body("{\"items\": [1, 2, 3]}"), def); Assert.True(result.IsValid); Assert.IsType>(result.Parameters["items"]); } // ── Undeclared / unexpected fields (rejected, recursively) ───────────────── [Fact] public void UnexpectedTopLevelField_ReturnsInvalid() { const string def = """{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"]}"""; // "valeu" is a typo for "value"; the caller must be told, not ignored. var result = ParameterValidator.Validate(Body("{\"value\": 1, \"valeu\": 2}"), def); Assert.False(result.IsValid); Assert.Contains("valeu", result.ErrorMessage); Assert.Contains("not a declared field", result.ErrorMessage); } [Fact] public void OnlyDeclaredFields_StillValid() { const string def = """{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"]}"""; var result = ParameterValidator.Validate(Body("{\"value\": 1}"), def); Assert.True(result.IsValid); Assert.Equal((long)1, result.Parameters["value"]); } [Fact] public void UndeclaredNestedField_ReturnsInvalid_PathQualified() { const string def = """ {"type":"object","properties":{ "order":{"type":"object","properties":{"id":{"type":"integer"}},"required":["id"]} },"required":["order"]} """; var result = ParameterValidator.Validate( Body("""{"order":{"id":1,"bogus":2}}"""), def); Assert.False(result.IsValid); Assert.Contains("order.bogus", result.ErrorMessage); Assert.Contains("not a declared field", result.ErrorMessage); } // ── Nested validation: the M2.6 core ─────────────────────────────────────── private const string NestedDef = """ { "type":"object", "properties":{ "order":{ "type":"object", "properties":{ "id":{"type":"integer"}, "customer":{ "type":"object", "properties":{"name":{"type":"string"},"vip":{"type":"boolean"}}, "required":["name"] }, "items":{ "type":"array", "items":{ "type":"object", "properties":{"sku":{"type":"string"},"quantity":{"type":"integer"}}, "required":["sku","quantity"] } } }, "required":["id","customer","items"] } }, "required":["order"] } """; [Fact] public void ValidNestedPayload_Passes() { const string body = """ {"order":{ "id":7, "customer":{"name":"Acme","vip":true}, "items":[ {"sku":"A1","quantity":3}, {"sku":"B2","quantity":1} ] }} """; var result = ParameterValidator.Validate(Body(body), NestedDef); Assert.True(result.IsValid); } [Fact] public void WrongScalarTwoLevelsDeep_ReturnsInvalid_WithExactPath() { // order.customer.vip declared boolean, given a string. const string body = """ {"order":{ "id":7, "customer":{"name":"Acme","vip":"yes"}, "items":[] }} """; var result = ParameterValidator.Validate(Body(body), NestedDef); Assert.False(result.IsValid); Assert.Contains("'order.customer.vip'", result.ErrorMessage); Assert.Contains("Boolean", result.ErrorMessage); } [Fact] public void WrongScalarInsideListElement_ReturnsInvalid_WithElementIndexInPath() { // order.items[1].quantity declared integer, given a string. const string body = """ {"order":{ "id":7, "customer":{"name":"Acme"}, "items":[ {"sku":"A1","quantity":3}, {"sku":"B2","quantity":"lots"} ] }} """; var result = ParameterValidator.Validate(Body(body), NestedDef); Assert.False(result.IsValid); Assert.Contains("'order.items[1].quantity'", result.ErrorMessage); Assert.Contains("Integer", result.ErrorMessage); } [Fact] public void ListElementWrongShape_ReturnsInvalid_WithElementIndexInPath() { // order.items[0] declared object, given a scalar. const string body = """ {"order":{ "id":7, "customer":{"name":"Acme"}, "items":[ 42 ] }} """; var result = ParameterValidator.Validate(Body(body), NestedDef); Assert.False(result.IsValid); Assert.Contains("'order.items[0]'", result.ErrorMessage); Assert.Contains("Object", result.ErrorMessage); } [Fact] public void MissingRequiredNestedField_ReturnsInvalid_PathQualified() { // order.customer.name is required but absent. const string body = """ {"order":{ "id":7, "customer":{"vip":false}, "items":[] }} """; var result = ParameterValidator.Validate(Body(body), NestedDef); Assert.False(result.IsValid); Assert.Contains("missing required field", result.ErrorMessage); Assert.Contains("'order.customer.name'", result.ErrorMessage); } // ── Empty / null edge cases ──────────────────────────────────────────────── [Fact] public void EmptyList_AgainstTypedElement_Passes() { const string body = """ {"order":{"id":7,"customer":{"name":"Acme"},"items":[]}} """; var result = ParameterValidator.Validate(Body(body), NestedDef); Assert.True(result.IsValid); } [Fact] public void NullForOptionalNestedScalar_Passes() { // order.customer.vip is optional; explicit null is accepted. const string body = """ {"order":{ "id":7, "customer":{"name":"Acme","vip":null}, "items":[] }} """; var result = ParameterValidator.Validate(Body(body), NestedDef); Assert.True(result.IsValid); } [Fact] public void NullForRequiredNestedScalar_Passes() { // A PRESENT-but-null required field satisfies the type — only ABSENCE // of a required field is an error (consistent with return-side policy). const string body = """ {"order":{ "id":null, "customer":{"name":"Acme"}, "items":[] }} """; var result = ParameterValidator.Validate(Body(body), NestedDef); Assert.True(result.IsValid); } // ── Legacy flat-array backward-compat ────────────────────────────────────── [Fact] public void LegacyFlatArrayDefinition_StillAccepted() { const string def = """[{"name":"count","type":"Integer","required":true}]"""; var ok = ParameterValidator.Validate(Body("{\"count\":5}"), def); Assert.True(ok.IsValid); Assert.Equal((long)5, ok.Parameters["count"]); var bad = ParameterValidator.Validate(Body("{\"count\":\"nope\"}"), def); Assert.False(bad.IsValid); Assert.Contains("'count'", bad.ErrorMessage); } // FIX 1: legacy "required":"false" string → field is optional ───────────── [Theory] [InlineData("""[{"name":"opt","type":"String","required":"false"}]""")] [InlineData("""[{"name":"opt","type":"String","required":"False"}]""")] [InlineData("""[{"name":"opt","type":"String","required":"FALSE"}]""")] public void LegacyFlatArray_RequiredStringFalse_FieldIsOptional(string def) { // An absent field whose "required" is the string "false" (any case) // must be treated as optional — consistent with the SQL migration's // LOWER(...) <> 'false' comparison that produced these rows. var result = ParameterValidator.Validate(null, def); Assert.True(result.IsValid, $"Expected optional field to be valid when absent; error: {result.ErrorMessage}"); } [Fact] public void LegacyFlatArray_RequiredStringFalse_FieldPresentAndTypedCorrectly_Passes() { const string def = """[{"name":"opt","type":"String","required":"false"}]"""; var result = ParameterValidator.Validate(Body("{\"opt\":\"hello\"}"), def); Assert.True(result.IsValid); } // FIX 2: recursion depth guard on Parse ─────────────────────────────────── /// /// Builds a JSON Schema string with levels of nested /// object-in-properties nesting. Each level wraps the previous in an object /// with a single property "a". The result exceeds the Parse ceiling when /// depth > 32. /// private static string BuildDeeplyNestedSchema(int depth) { // Inner-most: a scalar var schema = "{\"type\":\"string\"}"; for (var i = 0; i < depth; i++) { schema = "{\"type\":\"object\",\"properties\":{\"a\":" + schema + "}}"; } return schema; } [Fact] public void SchemaAtDepthCeiling_ParsesSuccessfully() { // Exactly 32 levels of nesting should parse without throwing. var def = BuildDeeplyNestedSchema(32); var schema = InboundApiSchema.Parse(def); Assert.NotNull(schema); } [Fact] public void SchemaExceedingDepthCeiling_ThrowsJsonException_NotStackOverflow() { // 33 levels exceeds the ceiling → JsonException (clean 400 via the // caller's try/catch), NOT a StackOverflowException. var def = BuildDeeplyNestedSchema(33); Assert.Throws(() => InboundApiSchema.Parse(def)); } [Fact] public void SchemaExceedingDepthCeiling_ParameterValidator_ReturnsInvalid() { // End-to-end: ParameterValidator wraps Parse in try/catch(JsonException) // → the caller gets Invalid rather than an unhandled exception. var def = BuildDeeplyNestedSchema(33); var result = ParameterValidator.Validate(Body("{\"a\":\"x\"}"), def); Assert.False(result.IsValid); Assert.Contains("Invalid parameter definitions", result.ErrorMessage); } }