using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// /// InboundAPI-014 / InboundAPI-M2.6: tests for return-value validation against a /// method's ReturnDefinition. Mirrors /// (shared recursive engine) — RECURSIVE nested Object / List-element type /// validation with path-qualified errors. /// /// /// Definitions are expressed as JSON Schema (the canonical persisted format); /// the legacy flat-array form is still accepted (final region). /// /// public class ReturnValueValidatorTests { // ── No definition → no validation (backward compatible) ─────────────────── [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void NoReturnDefinition_AnythingIsValid(string? returnDefinition) { var result = ReturnValueValidator.Validate("{\"anything\":1}", returnDefinition); Assert.True(result.IsValid); } [Fact] public void NoReturnDefinition_NullResult_IsValid() { var result = ReturnValueValidator.Validate(null, null); Assert.True(result.IsValid); } // ── Happy path: result matches the declared object shape ────────────────── [Fact] public void ResultMatchingDefinition_IsValid() { 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); Assert.True(result.IsValid); } [Fact] public void ResultWithListOfScalars_TypeChecked_IsValid() { 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); } // ── Scalar / shape mismatches must be reported ──────────────────────────── [Fact] public void ResultMissingDeclaredField_IsInvalid() { 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); Assert.False(result.IsValid); Assert.Contains("totalUnits", result.ErrorMessage); } [Fact] public void ResultFieldWrongType_IsInvalid() { const string def = """{"type":"object","properties":{"totalUnits":{"type":"integer"}},"required":["totalUnits"]}"""; const string json = """{"totalUnits":"not-a-number"}"""; var result = ReturnValueValidator.Validate(json, def); Assert.False(result.IsValid); Assert.Contains("totalUnits", result.ErrorMessage); } [Fact] public void NullResultWhenStructureRequired_IsInvalid() { const string def = """{"type":"object","properties":{"siteName":{"type":"string"}},"required":["siteName"]}"""; var result = ReturnValueValidator.Validate(null, def); Assert.False(result.IsValid); } [Fact] public void NonObjectResultWhenStructureRequired_IsInvalid() { const string def = """{"type":"object","properties":{"siteName":{"type":"string"}},"required":["siteName"]}"""; var result = ReturnValueValidator.Validate("42", def); Assert.False(result.IsValid); } [Fact] public void ListFieldGivenNonArray_IsInvalid() { 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); Assert.False(result.IsValid); Assert.Contains("lines", result.ErrorMessage); } [Fact] public void MalformedReturnDefinition_IsInvalid() { var result = ReturnValueValidator.Validate("{\"x\":1}", "%%% not json %%%"); 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); } // FIX 3: scalar return schema validates scalar return values ────────────── // (Guards the intentional ParameterValidator/ReturnValueValidator asymmetry: // ReturnValueValidator must NOT short-circuit on non-object schema types.) [Fact] public void ScalarStringReturnSchema_ValidatesScalarStringReturn() { // A {"type":"string"} return schema must accept a bare JSON string. var result = ReturnValueValidator.Validate("\"hello\"", """{"type":"string"}"""); Assert.True(result.IsValid); } [Fact] public void ScalarIntegerReturnSchema_ValidatesScalarIntegerReturn() { var result = ReturnValueValidator.Validate("42", """{"type":"integer"}"""); Assert.True(result.IsValid); } [Fact] public void ScalarStringReturnSchema_RejectsIntegerReturn() { var result = ReturnValueValidator.Validate("42", """{"type":"string"}"""); Assert.False(result.IsValid); Assert.Contains("String", result.ErrorMessage); } [Fact] public void ScalarBooleanReturnSchema_ValidatesBooleanReturn() { var result = ReturnValueValidator.Validate("true", """{"type":"boolean"}"""); Assert.True(result.IsValid); } // FIX 2: recursion depth guard on Validate ───────────────────────────────── [Fact] public void ValidateExceedingDepthCeiling_AddsDepthError_DoesNotThrow() { // Build a schema programmatically (bypassing Parse) with 34 levels of // nesting to exceed the ceiling of 32. Validate must add an error and // return, NOT stack overflow. // // Parse prevents creating a >32-level schema from stored JSON, but // InboundApiSchema is a public type constructable in code, so Validate // must guard independently. var deepSchema = BuildProgrammaticSchema(34); var json = BuildDeeplyNestedValue(34); using var doc = System.Text.Json.JsonDocument.Parse(json); var errors = new System.Collections.Generic.List(); // Must not throw — adds a depth error to the list instead. deepSchema.Validate(doc.RootElement, string.Empty, errors); Assert.NotEmpty(errors); Assert.Contains("nesting too deep", errors[0], StringComparison.OrdinalIgnoreCase); } /// /// Constructs an with /// levels of object-nesting programmatically (bypassing Parse) to /// exercise the Validate depth ceiling independently of the Parse ceiling. /// private static InboundApiSchema BuildProgrammaticSchema(int depth) { InboundApiSchema inner = new() { Type = "string" }; for (var i = 0; i < depth; i++) { inner = new InboundApiSchema { Type = "object", Fields = [new InboundApiSchemaField("a", Required: false, inner)], }; } return inner; } private static string BuildDeeplyNestedValue(int depth) { var value = "\"leaf\""; for (var i = 0; i < depth; i++) { value = "{\"a\":" + value + "}"; } return value; } }