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); } // ── Recursive materialization: nested values coerced to typed CLR (#55) ───── /// /// Recursively asserts that no value anywhere in a materialized parameter /// graph is a raw — every scalar must be a typed CLR /// value (string/long/double/bool/null) and every container a /// Dictionary<string, object?> / List<object?> (or a /// typed scalar list) of likewise-coerced values. /// private static void AssertNoJsonElement(object? value) { Assert.IsNotType(value); switch (value) { case IDictionary dict: foreach (var v in dict.Values) { AssertNoJsonElement(v); } break; case System.Collections.IEnumerable enumerable when value is not string: foreach (var item in enumerable) { AssertNoJsonElement(item); } break; } } [Fact] public void NestedObjectFieldScalar_IsTypedClr_NotJsonElement() { const string body = """ {"order":{ "id":7, "customer":{"name":"Acme","vip":true}, "items":[] }} """; var result = ParameterValidator.Validate(Body(body), NestedDef); Assert.True(result.IsValid); var order = Assert.IsType>(result.Parameters["order"]); AssertNoJsonElement(order); // The nested scalar fields are the CLR types the top-level path produces: // integer→long, string→string, boolean→bool. Assert.Equal((long)7, order["id"]); var customer = Assert.IsType>(order["customer"]); Assert.Equal("Acme", customer["name"]); Assert.Equal(true, customer["vip"]); Assert.IsType(customer["vip"]); Assert.IsType(order["id"]); } [Fact] public void ListOfScalars_FullyCoerced_NoJsonElement() { const string def = """{"type":"object","properties":{"nums":{"type":"array","items":{"type":"integer"}}},"required":["nums"]}"""; var result = ParameterValidator.Validate(Body("{\"nums\":[1,2,3]}"), def); Assert.True(result.IsValid); // Declared scalar element → strongly-typed List. var nums = Assert.IsType>(result.Parameters["nums"]); Assert.Equal(new List { 1, 2, 3 }, nums); AssertNoJsonElement(nums); } [Fact] public void ListOfObjects_ElementFieldsCoerced_NoJsonElement() { const string def = """ {"type":"object","properties":{ "items":{"type":"array","items":{ "type":"object", "properties":{"sku":{"type":"string"},"quantity":{"type":"integer"},"price":{"type":"number"}}, "required":["sku","quantity"] }} },"required":["items"]} """; const string body = """{"items":[{"sku":"A1","quantity":3,"price":9.5},{"sku":"B2","quantity":1,"price":2.0}]}"""; var result = ParameterValidator.Validate(Body(body), def); Assert.True(result.IsValid); var items = Assert.IsType>(result.Parameters["items"]); AssertNoJsonElement(items); var first = Assert.IsType>(items[0]); Assert.Equal("A1", first["sku"]); Assert.Equal((long)3, first["quantity"]); Assert.IsType(first["quantity"]); Assert.Equal(9.5, first["price"]); Assert.IsType(first["price"]); } [Fact] public void DeeplyNested_ObjectInListInObject_FullyCoerced_NoJsonElement() { // order.items[*] is an object that itself carries a list of tag objects: // object → array → object → scalar, the worst-case leak path. const string def = """ {"type":"object","properties":{ "order":{"type":"object","properties":{ "id":{"type":"integer"}, "items":{"type":"array","items":{ "type":"object","properties":{ "sku":{"type":"string"}, "tags":{"type":"array","items":{ "type":"object","properties":{ "key":{"type":"string"}, "weight":{"type":"number"}, "active":{"type":"boolean"} },"required":["key"] }} },"required":["sku"] }} },"required":["id","items"]} },"required":["order"]} """; const string body = """ {"order":{ "id":7, "items":[ {"sku":"A1","tags":[{"key":"hot","weight":1.5,"active":true},{"key":"cold","weight":0.25,"active":false}]} ] }} """; var result = ParameterValidator.Validate(Body(body), def); Assert.True(result.IsValid); // The whole graph must be free of JsonElement at every depth. AssertNoJsonElement(result.Parameters["order"]); var order = Assert.IsType>(result.Parameters["order"]); Assert.Equal((long)7, order["id"]); var items = Assert.IsType>(order["items"]); var item0 = Assert.IsType>(items[0]); Assert.Equal("A1", item0["sku"]); var tags = Assert.IsType>(item0["tags"]); var tag0 = Assert.IsType>(tags[0]); Assert.Equal("hot", tag0["key"]); Assert.Equal(1.5, tag0["weight"]); Assert.IsType(tag0["weight"]); Assert.Equal(true, tag0["active"]); Assert.IsType(tag0["active"]); var tag1 = Assert.IsType>(tags[1]); Assert.Equal(false, tag1["active"]); } [Fact] public void ShapeOnlyNestedObject_StillFullyCoerced_NoJsonElement() { // A declared-but-shape-only object field ({"type":"object"} with no // properties) is still coerced structurally — no JsonElement survives. // Without a declared field schema there is no integer/float distinction, // so (per the pre-existing structural-coercion path) numbers come back as // double; the point of THIS test is the absence of JsonElement, not the // integer-vs-double choice (the declared paths cover that). const string def = """{"type":"object","properties":{"data":{"type":"object"}},"required":["data"]}"""; const string body = """{"data":{"name":"x","count":5,"ratio":1.5,"flag":true,"nested":{"inner":2}}}"""; var result = ParameterValidator.Validate(Body(body), def); Assert.True(result.IsValid); var data = Assert.IsType>(result.Parameters["data"]); AssertNoJsonElement(data); Assert.Equal("x", data["name"]); Assert.Equal(5d, data["count"]); Assert.Equal(1.5, data["ratio"]); Assert.Equal(true, data["flag"]); var nested = Assert.IsType>(data["nested"]); Assert.Equal(2d, nested["inner"]); } // ── 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); } }