From bd069eabf22be83a4c1628aa352cceb72231c417 Mon Sep 17 00:00:00 2001 From: hamzaalqurneh Date: Mon, 1 Jun 2026 13:30:29 +0300 Subject: [PATCH] Refactor: Remove access check in Preview handler and add unit tests for Scriban JSON mapping - Removed the access check for Admin and Member roles in the Preview handler. - Added unit tests for NativeJSONMapper to ensure correct mapping using startup templates. - Introduced tests for RunMapperEnrichment to validate JSON parsing and enrichment logic. - Created ScribanGeneratorParityTests to ensure parity with the output of the Scriban generator. - Added ScribanJsonHelperArrayMappingTests to verify array mapping functionality. - Implemented ScribanJsonHelperErrorHandlingTests to check error handling for invalid templates. - Developed ScribanJsonHelperLookupAndTypeRuleTests to validate lookup and type rule mappings. - Added ScribanJsonHelperRootMappingTests to test root mapping functionality. - Created a helper class for rendering JSON templates and asserting JSON equality. --- SW.Bitween.Api/Resources/Mappers/Preview.cs | 2 - SW.Bitween.UnitTests/NativeJsonMapperTests.cs | 54 +++ .../RunMapperEnrichmentTests.cs | 85 ++++ .../ScribanGeneratorParityTests.cs | 410 ++++++++++++++++++ .../ScribanJsonHelperArrayMappingTests.cs | 185 ++++++++ .../ScribanJsonHelperErrorHandlingTests.cs | 42 ++ ...ScribanJsonHelperLookupAndTypeRuleTests.cs | 158 +++++++ .../ScribanJsonHelperRootMappingTests.cs | 162 +++++++ SW.Bitween.UnitTests/ScribanJsonTestHelper.cs | 31 ++ 9 files changed, 1127 insertions(+), 2 deletions(-) create mode 100644 SW.Bitween.UnitTests/NativeJsonMapperTests.cs create mode 100644 SW.Bitween.UnitTests/RunMapperEnrichmentTests.cs create mode 100644 SW.Bitween.UnitTests/ScribanGeneratorParityTests.cs create mode 100644 SW.Bitween.UnitTests/ScribanJsonHelperArrayMappingTests.cs create mode 100644 SW.Bitween.UnitTests/ScribanJsonHelperErrorHandlingTests.cs create mode 100644 SW.Bitween.UnitTests/ScribanJsonHelperLookupAndTypeRuleTests.cs create mode 100644 SW.Bitween.UnitTests/ScribanJsonHelperRootMappingTests.cs create mode 100644 SW.Bitween.UnitTests/ScribanJsonTestHelper.cs diff --git a/SW.Bitween.Api/Resources/Mappers/Preview.cs b/SW.Bitween.Api/Resources/Mappers/Preview.cs index 3975deab..e228d8cf 100644 --- a/SW.Bitween.Api/Resources/Mappers/Preview.cs +++ b/SW.Bitween.Api/Resources/Mappers/Preview.cs @@ -37,8 +37,6 @@ public Preview(RequestContext requestContext, BitweenDbContext dbContext) public async Task Handle(MapperPreviewRequest request) { - _requestContext.EnsureAccess(AccountRole.Admin, AccountRole.Member); - var partner = request.PartnerId.HasValue ? await _dbContext.FindAsync(request.PartnerId.Value) : null; diff --git a/SW.Bitween.UnitTests/NativeJsonMapperTests.cs b/SW.Bitween.UnitTests/NativeJsonMapperTests.cs new file mode 100644 index 00000000..d75ec2d0 --- /dev/null +++ b/SW.Bitween.UnitTests/NativeJsonMapperTests.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.NativeAdapters; +using SW.PrimitiveTypes; +using static SW.Bitween.UnitTests.ScribanJsonTestHelper; + +namespace SW.Bitween.UnitTests; + +[TestClass] +public class NativeJsonMapperTests +{ + [TestMethod] + public async Task NativeJsonMapper_MapsInputUsingStartupTemplate() + { + var mapper = new NativeJSONMapper(); + mapper.InitializeStartupValues(new Dictionary + { + ["ScribanTemplate"] = "{ \"id\": {{ orderId | json }} }" + }); + + var input = new XchangeFile("{\"orderId\":\"123\"}"); + var output = await mapper.Handle(input); + + AssertJsonEquals("{\"id\":\"123\"}", output.Data); + } + + [TestMethod] + public async Task NativeJsonMapper_WhenTemplateMissing_UsesDefaultEmptyObject() + { + var mapper = new NativeJSONMapper(); + mapper.InitializeStartupValues(new Dictionary()); + + var output = await mapper.Handle(new XchangeFile("{\"x\":1}")); + + AssertJsonEquals("{}", output.Data); + } + + [TestMethod] + public async Task NativeJsonMapper_ReturnsNewXchangeFileInstance() + { + var mapper = new NativeJSONMapper(); + mapper.InitializeStartupValues(new Dictionary + { + ["ScribanTemplate"] = "{ \"x\": {{ x | json }} }" + }); + + var input = new XchangeFile("{\"x\":1}"); + var output = await mapper.Handle(input); + + Assert.AreNotSame(input, output); + AssertJsonEquals("{\"x\":1}", output.Data); + } +} diff --git a/SW.Bitween.UnitTests/RunMapperEnrichmentTests.cs b/SW.Bitween.UnitTests/RunMapperEnrichmentTests.cs new file mode 100644 index 00000000..90562a4b --- /dev/null +++ b/SW.Bitween.UnitTests/RunMapperEnrichmentTests.cs @@ -0,0 +1,85 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace SW.Bitween.UnitTests; + +[TestClass] +public class RunMapperEnrichmentTests +{ + // Replicates the fixed parsing logic in XchangeService.RunMapper + private static JObject TryParseAsJObject(string data) => + JToken.Parse(data) as JObject; + + [TestMethod] + [Description("Receiver returns a plain JSON string — should not throw and should skip enrichment")] + public void WhenDataIsJsonString_ShouldNotThrow_AndReturnNull() + { + var data = "\"this is a plain string response from the receiver\""; + + var result = TryParseAsJObject(data); + + Assert.IsNull(result, "Expected null because data is a JSON string, not an object"); + } + + [TestMethod] + [Description("Receiver returns a JSON object — enrichment should proceed normally")] + public void WhenDataIsJsonObject_ShouldReturnJObject() + { + var data = "{\"orderId\":\"123\",\"status\":\"pending\"}"; + + var result = TryParseAsJObject(data); + + Assert.IsNotNull(result, "Expected a JObject because data is a valid JSON object"); + Assert.AreEqual("123", result["orderId"]?.ToString()); + } + + [TestMethod] + [Description("Receiver returns a JSON array — should not throw and should skip enrichment")] + public void WhenDataIsJsonArray_ShouldNotThrow_AndReturnNull() + { + var data = "[\"item1\",\"item2\"]"; + + var result = TryParseAsJObject(data); + + Assert.IsNull(result, "Expected null because data is a JSON array, not an object"); + } + + [TestMethod] + [Description("Large JSON string payload (like the one that triggered the original error) — should not throw")] + public void WhenDataIsLargeJsonString_ShouldNotThrow_AndReturnNull() + { + // Simulate the case from the error: a JSON-encoded string with ~200k chars + var innerString = new string('x', 200_000); + var data = $"\"{innerString}\""; + + JObject result = null; + var threw = false; + + try + { + result = TryParseAsJObject(data); + } + catch + { + threw = true; + } + + Assert.IsFalse(threw, "Should not throw for a large JSON string payload"); + Assert.IsNull(result, "Expected null because data is a JSON string, not an object"); + } + + [TestMethod] + [Description("Enrichment is injected into a JSON object when partner properties exist")] + public void WhenDataIsJsonObject_PartnerPropertiesCanBeInjected() + { + var data = "{\"orderId\":\"123\"}"; + var partnerProps = new { apiKey = "abc", region = "us-east" }; + + var jObj = TryParseAsJObject(data); + + Assert.IsNotNull(jObj); + jObj["__partner__"] = JObject.FromObject(partnerProps); + + Assert.AreEqual("abc", jObj["__partner__"]?["apiKey"]?.ToString()); + } +} diff --git a/SW.Bitween.UnitTests/ScribanGeneratorParityTests.cs b/SW.Bitween.UnitTests/ScribanGeneratorParityTests.cs new file mode 100644 index 00000000..6ced9280 --- /dev/null +++ b/SW.Bitween.UnitTests/ScribanGeneratorParityTests.cs @@ -0,0 +1,410 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; +using System; + +namespace SW.Bitween.UnitTests; + +/// +/// Parity tests: each template string here is the EXACT output of scribanGenerator.ts +/// for the corresponding mapping config (frozen in scribanGeneratorOutput.test.ts snapshots). +/// +/// If a test here fails it means ScribanJsonHelper.Render cannot execute a template +/// that the UI generator produces — a production bug. +/// If the TypeScript snapshot changes, the template constant here must be updated to match. +/// +[TestClass] +public class ScribanGeneratorParityTests +{ + // ── Source field rename ──────────────────────────────────────────────────── + // Config: { target: 'orderId', source: 'Order.Id' } + + [TestMethod] + public void Parity_SourceFieldRename_RendersCorrectly() + { + const string template = """ + { + "orderId": {{ Order.Id | json }}, + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, """{"Order": {"Id": "ORD-001"}}"""); + Assert.AreEqual("ORD-001", result["orderId"]?.ToString()); + } + + // ── Fixed string value ───────────────────────────────────────────────────── + // Config: { target: 'status', source: '', fixedValue: 'active' } + + [TestMethod] + public void Parity_FixedStringValue_RendersCorrectly() + { + const string template = """ + { + "status": "active", + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, "{}"); + Assert.AreEqual("active", result["status"]?.ToString()); + } + + // ── Fixed number value ───────────────────────────────────────────────────── + // Config: { target: 'count', source: '', fixedValue: '42' } + + [TestMethod] + public void Parity_FixedNumberValue_RendersCorrectly() + { + const string template = """ + { + "count": 42, + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, "{}"); + Assert.AreEqual(42, result["count"]?.Value()); + } + + // ── Fixed boolean value ──────────────────────────────────────────────────── + // Config: { target: 'flag', source: '', fixedValue: 'true' } + + [TestMethod] + public void Parity_FixedBooleanValue_RendersCorrectly() + { + const string template = """ + { + "flag": true, + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, "{}"); + Assert.AreEqual(true, result["flag"]?.Value()); + } + + // ── Partner property ─────────────────────────────────────────────────────── + // Config: { target: 'pkey', source: '', partnerPropKey: 'apiKey' } + + [TestMethod] + public void Parity_PartnerProperty_RendersCorrectly() + { + const string template = """ + { + "pkey": {{ __partner__?.apiKey | json }}, + } + """; + const string inputJson = """{"__partner__": {"apiKey": "secret-123"}}"""; + var result = ScribanJsonTestHelper.RenderObject(template, inputJson); + Assert.AreEqual("secret-123", result["pkey"]?.ToString()); + } + + [TestMethod] + public void Parity_PartnerProperty_MissingPartner_ReturnsNull() + { + const string template = """ + { + "pkey": {{ __partner__?.apiKey | json }}, + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, "{}"); + Assert.AreEqual(JTokenType.Null, result["pkey"]?.Type); + } + + // ── Global set key ───────────────────────────────────────────────────────── + // Config: { target: 'gval', source: '', globalSetId: 'mySet', globalKey: 'region' } + + [TestMethod] + public void Parity_GlobalSetKey_RendersCorrectly() + { + const string template = """ + { + "gval": {{ __globals__?.mySet["region"] | json }}, + } + """; + const string inputJson = """{"__globals__": {"mySet": {"region": "eu-west"}}}"""; + var result = ScribanJsonTestHelper.RenderObject(template, inputJson); + Assert.AreEqual("eu-west", result["gval"]?.ToString()); + } + + // ── Lookup — null fallback ───────────────────────────────────────────────── + // Config: { target: 'category', source: 'Cat', lookupDictionary: { entries:[{A→Alpha}], fallback:'null' } } + + [TestMethod] + public void Parity_LookupNullFallback_HitReturnsValue() + { + const string template = """ + { + "category": {{ $__e = { "A": "Alpha" }; $__e[Cat] | json }}, + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, """{"Cat": "A"}"""); + Assert.AreEqual("Alpha", result["category"]?.ToString()); + } + + [TestMethod] + public void Parity_LookupNullFallback_MissReturnsNull() + { + const string template = """ + { + "category": {{ $__e = { "A": "Alpha" }; $__e[Cat] | json }}, + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, """{"Cat": "Z"}"""); + Assert.AreEqual(JTokenType.Null, result["category"]?.Type); + } + + // ── Lookup — custom fallback ─────────────────────────────────────────────── + // Config: { fallback:'custom', fallbackValue:'Unknown' } + + [TestMethod] + public void Parity_LookupCustomFallback_MissReturnsCustomValue() + { + const string template = """ + { + "category": {{ $__e = { "A": "Alpha" }; ($__e[Cat] ?? "Unknown") | json }}, + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, """{"Cat": "Z"}"""); + Assert.AreEqual("Unknown", result["category"]?.ToString()); + } + + // ── Transform ────────────────────────────────────────────────────────────── + // Config: { target: 'doubled', source: 'amount', transform: 'value * 2' } + + [TestMethod] + public void Parity_Transform_ArithmeticExpression_RendersCorrectly() + { + const string template = """ + { + "doubled": {{ amount * 2 | json }}, + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, """{"amount": 5}"""); + Assert.AreEqual(10, result["doubled"]?.Value()); + } + + // ── Object array mapping ─────────────────────────────────────────────────── + // Config: source:Items → target:lines, alias:item, mappings:[sku←Sku, qty←Quantity] + + [TestMethod] + public void Parity_ObjectArrayMapping_RendersAllItems() + { + const string template = """ + { + "lines": [ + {{- for item in Items -}} + { + "sku": {{ item.Sku | json }}, + "qty": {{ item.Quantity | json }}, + }, + {{- end -}} + ], + } + """; + const string inputJson = """ + { + "Items": [ + {"Sku": "AAA", "Quantity": 2}, + {"Sku": "BBB", "Quantity": 5} + ] + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, inputJson); + var lines = result["lines"] as JArray; + Assert.IsNotNull(lines); + Assert.AreEqual(2, lines.Count); + Assert.AreEqual("AAA", lines[0]["sku"]?.ToString()); + Assert.AreEqual(5, lines[1]["qty"]?.Value()); + } + + // ── Type rules ──────────────────────────────────────────────────────────── + // Each template string is the EXACT output of generateScriban when outputJson + // and inputJson are provided and their types differ, triggering castExpr. + + [TestMethod] + public void Parity_TypeRule_BoolToString_EmitsCorrectCast() + { + // generateScriban: outputJson={"result":""}, inputJson={"flag":true} + // → castExpr('flag','string','boolean') = (flag == null ? null : (flag ? "true" : "false")) + const string template = """ + { + "result": {{ (flag == null ? null : (flag ? "true" : "false")) | json }}, + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, """{"flag": true}"""); + Assert.AreEqual("true", result["result"]?.ToString()); + + var result2 = ScribanJsonTestHelper.RenderObject(template, """{"flag": false}"""); + Assert.AreEqual("false", result2["result"]?.ToString()); + } + + [TestMethod] + public void Parity_TypeRule_NumberToString_EmitsCorrectCast() + { + // generateScriban: outputJson={"result":""}, inputJson={"count":42} + // → castExpr('count','string','number') = ("" + count) + const string template = """ + { + "result": {{ ("" + count) | json }}, + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, """{"count": 42}"""); + Assert.AreEqual(JTokenType.String, result["result"]?.Type); + Assert.AreEqual("42", result["result"]?.ToString()); + } + + [TestMethod] + public void Parity_TypeRule_StringToNumber_UsesToFloat() + { + // generateScriban: outputJson={"result":0}, inputJson={"price":"9.99"} + // → castExpr('price','number','string') = (price | to_float) + const string template = """ + { + "result": {{ (price | to_float) | json }}, + } + """; + var hit = ScribanJsonTestHelper.RenderObject(template, """{"price": "9.99"}"""); + Assert.AreEqual(JTokenType.Float, hit["result"]?.Type); + Assert.IsTrue(Math.Abs((hit["result"]?.Value() ?? 0) - 9.99) < 0.0001); + + // invalid string → null + var miss = ScribanJsonTestHelper.RenderObject(template, """{"price": "abc"}"""); + Assert.AreEqual(JTokenType.Null, miss["result"]?.Type); + } + + [TestMethod] + public void Parity_TypeRule_BoolToNumber_EmitsCorrectCast() + { + // generateScriban: outputJson={"result":0}, inputJson={"flag":true} + // → castExpr('flag','number','boolean') = (flag == null ? null : (flag ? 1 : 0)) + const string template = """ + { + "result": {{ (flag == null ? null : (flag ? 1 : 0)) | json }}, + } + """; + var trueResult = ScribanJsonTestHelper.RenderObject(template, """{"flag": true}"""); + Assert.AreEqual(1, trueResult["result"]?.Value()); + + var falseResult = ScribanJsonTestHelper.RenderObject(template, """{"flag": false}"""); + Assert.AreEqual(0, falseResult["result"]?.Value()); + } + + [TestMethod] + public void Parity_TypeRule_NumberToBool_EmitsCorrectCast() + { + // generateScriban: outputJson={"result":false}, inputJson={"amount":1} + // → castExpr('amount','boolean','number') = (amount == 0 ? false : (amount == 1 ? true : null)) + const string template = """ + { + "result": {{ (amount == 0 ? false : (amount == 1 ? true : null)) | json }}, + } + """; + var zero = ScribanJsonTestHelper.RenderObject(template, """{"amount": 0}"""); + Assert.AreEqual(false, zero["result"]?.Value()); + + var one = ScribanJsonTestHelper.RenderObject(template, """{"amount": 1}"""); + Assert.AreEqual(true, one["result"]?.Value()); + + var other = ScribanJsonTestHelper.RenderObject(template, """{"amount": 9}"""); + Assert.AreEqual(JTokenType.Null, other["result"]?.Type); + } + + [TestMethod] + public void Parity_TransformWithStringTarget_EmitsTypedExpression() + { + // generateScriban: source='price', transform='value * 2', outputJson={"result":""}, inputJson={"price":5} + const string template = """ + { + "result": {{ $__t = (price * 2); ($__t == null ? null : ((($__t | object.typeof) == "object" || ($__t | object.typeof) == "array") ? null : (($__t | object.typeof) == "boolean" ? ($__t ? "true" : "false") : ("" + $__t)))) | json }}, + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, """{"price": 5}"""); + Assert.AreEqual(JTokenType.String, result["result"]?.Type); + Assert.AreEqual("10", result["result"]?.ToString()); + } + + [TestMethod] + public void Parity_TransformWithNumberTarget_EmitsTypedExpression() + { + // generateScriban: source='amount', transform='value > 100', outputJson={"result":0}, inputJson={"amount":200} + // bool result (true) → number via ($__t ? 1 : 0) + const string template = """ + { + "result": {{ $__t = (amount > 100); ($__t == null ? null : (($__t | object.typeof) == "boolean" ? ($__t ? 1 : 0) : ((($__t | object.typeof) == "object" || ($__t | object.typeof) == "array") ? null : ($__t | to_float)))) | json }}, + } + """; + var above = ScribanJsonTestHelper.RenderObject(template, """{"amount": 200}"""); + Assert.AreEqual(JTokenType.Integer, above["result"]?.Type); + Assert.AreEqual(1, above["result"]?.Value()); + + var below = ScribanJsonTestHelper.RenderObject(template, """{"amount": 50}"""); + Assert.AreEqual(0, below["result"]?.Value()); + } + + [TestMethod] + public void Parity_IsRootSource_FieldInsideArrayLoopReadsFromRoot() + { + // generateScriban: ArrayMapping with one item field (item.Sku) and one + // isRootSource field (OrderId — no alias prefix, reads from root object) + const string template = """ + { + "lines": [ + {{- for item in Items -}} + { + "sku": {{ item.Sku | json }}, + "orderId": {{ OrderId | json }}, + }, + {{- end -}} + ], + } + """; + const string inputJson = """ + { + "OrderId": "ORD-99", + "Items": [ + {"Sku": "AAA"}, + {"Sku": "BBB"} + ] + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, inputJson); + var lines = result["lines"] as JArray; + Assert.IsNotNull(lines); + Assert.AreEqual(2, lines.Count); + // item field: different per row + Assert.AreEqual("AAA", lines[0]["sku"]?.ToString()); + Assert.AreEqual("BBB", lines[1]["sku"]?.ToString()); + // root field: same value on every row + Assert.AreEqual("ORD-99", lines[0]["orderId"]?.ToString()); + Assert.AreEqual("ORD-99", lines[1]["orderId"]?.ToString()); + } + + // ── Array with filter ────────────────────────────────────────────────────── + // Config: source:Items → target:active, filter: Status == "active" + + [TestMethod] + public void Parity_ArrayWithFilter_OnlyIncludesMatchingItems() + { + const string template = """ + { + "active": [ + {{- for item in Items -}} + {{- if item.Status == "active" -}} + { + "name": {{ item.Name | json }}, + }, + {{- end -}} + {{- end -}} + ], + } + """; + const string inputJson = """ + { + "Items": [ + {"Name": "Alice", "Status": "active"}, + {"Name": "Bob", "Status": "inactive"}, + {"Name": "Carol", "Status": "active"} + ] + } + """; + var result = ScribanJsonTestHelper.RenderObject(template, inputJson); + var active = result["active"] as JArray; + Assert.IsNotNull(active); + Assert.AreEqual(2, active.Count); + Assert.AreEqual("Alice", active[0]["name"]?.ToString()); + Assert.AreEqual("Carol", active[1]["name"]?.ToString()); + } +} diff --git a/SW.Bitween.UnitTests/ScribanJsonHelperArrayMappingTests.cs b/SW.Bitween.UnitTests/ScribanJsonHelperArrayMappingTests.cs new file mode 100644 index 00000000..a4449aba --- /dev/null +++ b/SW.Bitween.UnitTests/ScribanJsonHelperArrayMappingTests.cs @@ -0,0 +1,185 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using static SW.Bitween.UnitTests.ScribanJsonTestHelper; + +namespace SW.Bitween.UnitTests; + +[TestClass] +public class ScribanJsonHelperArrayMappingTests +{ + [TestMethod] + public void ObjectArrayMapping_SupportsSourceLookupTransformFixedPartnerGlobal() + { + var input = "{\"items\":[{\"sku\":\"A\",\"price\":100},{\"sku\":\"B\",\"price\":50}],\"__partner__\":{\"apiKey\":\"p-1\"},\"__globals__\":{\"S\":{\"K\":\"g-1\"}}}"; + var template = + "{ \"out\": [" + + "{{ for item in items }}" + + "{ " + + "\"fromSource\": {{ item.sku | json }}, " + + "\"fromLookup\": {{ $__e = { \"A\": \"Alpha\", \"B\": \"Beta\" }; $__e[item.sku] | json }}, " + + "\"fromTransform\": {{ (item.price * 2) | json }}, " + + "\"fromFixed\": \"STATIC\", " + + "\"fromPartner\": {{ __partner__?.apiKey | json }}, " + + "\"fromGlobal\": {{ __globals__?.S[\"K\"] | json }} " + + "}," + + "{{ end }}" + + "] }"; + + var output = Render(template, input); + + AssertJsonEquals( + "{\"out\":[{" + + "\"fromSource\":\"A\",\"fromLookup\":\"Alpha\",\"fromTransform\":200,\"fromFixed\":\"STATIC\",\"fromPartner\":\"p-1\",\"fromGlobal\":\"g-1\"},{" + + "\"fromSource\":\"B\",\"fromLookup\":\"Beta\",\"fromTransform\":100,\"fromFixed\":\"STATIC\",\"fromPartner\":\"p-1\",\"fromGlobal\":\"g-1\"}]}", + output); + } + + [TestMethod] + public void ObjectArrayMapping_SupportsFilter() + { + var input = "{\"items\":[{\"id\":1,\"active\":true},{\"id\":2,\"active\":false}]}"; + var template = + "{ \"out\": [" + + "{{ for item in items }}" + + "{{ if item.active == true }}" + + "{ \"id\": {{ item.id | json }} }," + + "{{ end }}" + + "{{ end }}" + + "] }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"out\":[{\"id\":1}]}", output); + } + + [TestMethod] + public void ObjectArrayMapping_HandlesNestedObjectsInsideItems() + { + var input = "{\"items\":[{\"customer\":{\"address\":{\"city\":\"Amman\"}}}]}"; + var template = + "{ \"out\": [" + + "{{ for item in items }}" + + "{ \"city\": {{ item.customer.address.city | json }} }," + + "{{ end }}" + + "] }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"out\":[{\"city\":\"Amman\"}]}", output); + } + + [TestMethod] + public void PrimitiveArrayMapping_SupportsSourceFixedPartnerGlobal() + { + var input = "{\"tags\":[\"a\",\"b\"],\"__partner__\":{\"k\":\"p\"},\"__globals__\":{\"S\":{\"K\":\"g\"}}}"; + var template = + "{ \"out\": [" + + "{{ for tag in tags }}" + + "{ \"source\": {{ tag | json }}, \"fixed\": \"FX\", \"partner\": {{ __partner__?.k | json }}, \"global\": {{ __globals__?.S[\"K\"] | json }} }," + + "{{ end }}" + + "] }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"out\":[{\"source\":\"a\",\"fixed\":\"FX\",\"partner\":\"p\",\"global\":\"g\"},{\"source\":\"b\",\"fixed\":\"FX\",\"partner\":\"p\",\"global\":\"g\"}]}", output); + } + + [TestMethod] + public void FixedArrayItems_CanIncludeSourcePartnerAndGlobalExpressions() + { + var input = "{\"orderId\":\"C-1\",\"__partner__\":{\"env\":\"prod\"},\"__globals__\":{\"A\":{\"B\":\"v1\"}}}"; + var template = + "{ \"out\": [" + + "{ \"name\": \"order\", \"value\": {{ orderId | json }} }," + + "{ \"name\": \"env\", \"value\": {{ __partner__?.env | json }} }," + + "{ \"name\": \"global\", \"value\": {{ __globals__?.A[\"B\"] | json }} }," + + "{ \"name\": \"fixed\", \"value\": \"x\" }" + + "] }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"out\":[{\"name\":\"order\",\"value\":\"C-1\"},{\"name\":\"env\",\"value\":\"prod\"},{\"name\":\"global\",\"value\":\"v1\"},{\"name\":\"fixed\",\"value\":\"x\"}]}", output); + } + + [TestMethod] + public void FixedArrayItems_AddAndEditScenario_ReflectsUpdatedTemplate() + { + var input = "{\"orderId\":\"C-1\"}"; + var beforeTemplate = "{ \"out\": [ { \"name\": \"order\", \"value\": {{ orderId | json }} } ] }"; + var afterTemplate = "{ \"out\": [ { \"name\": \"order\", \"value\": \"edited\" }, { \"name\": \"new\", \"value\": 2 } ] }"; + + var before = Render(beforeTemplate, input); + var after = Render(afterTemplate, input); + + AssertJsonEquals("{\"out\":[{\"name\":\"order\",\"value\":\"C-1\"}]}", before); + AssertJsonEquals("{\"out\":[{\"name\":\"order\",\"value\":\"edited\"},{\"name\":\"new\",\"value\":2}]}", after); + } + + [TestMethod] + public void NestedArrays_ThreeLevels_AreRenderedCorrectly() + { + var input = + "{" + + "\"companies\":[{" + + "\"name\":\"Acme\"," + + "\"departments\":[{" + + "\"name\":\"IT\"," + + "\"employees\":[{\"name\":\"Alice\"},{\"name\":\"Bob\"}]" + + "}]" + + "}]" + + "}"; + + var template = + "{ \"out\": [" + + "{{ for co in companies }}" + + "{ \"company\": {{ co.name | json }}, \"departments\": [" + + "{{ for dep in co.departments }}" + + "{ \"department\": {{ dep.name | json }}, \"staff\": [" + + "{{ for emp in dep.employees }}" + + "{ \"employee\": {{ emp.name | json }} }," + + "{{ end }}" + + "] }," + + "{{ end }}" + + "] }," + + "{{ end }}" + + "] }"; + + var output = Render(template, input); + + AssertJsonEquals( + "{\"out\":[{\"company\":\"Acme\",\"departments\":[{\"department\":\"IT\",\"staff\":[{\"employee\":\"Alice\"},{\"employee\":\"Bob\"}]}]}]}", + output); + } + + [TestMethod] + public void SmartArray_AllowsFirstItemMemberAccessWithoutIndex() + { + var input = "{\"items\":[{\"city\":\"Amman\"}]}"; + var template = "{ \"city\": {{ items.city | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"city\":\"Amman\"}", output); + } + + [TestMethod] + public void EmptyArray_ProducesEmptyOutputArray() + { + var input = "{\"items\":[]}"; + var template = "{ \"out\": [{{ for item in items }}{\"x\":1},{{ end }}] }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"out\":[]}", output); + } + + [TestMethod] + public void TrailingCommas_AreRemovedFromRenderedJson() + { + var input = "{\"items\":[1,2]}"; + var template = "{ \"arr\": [{{ for i in items }}{{ i | json }},{{ end }}], }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"arr\":[1,2]}", output); + } +} diff --git a/SW.Bitween.UnitTests/ScribanJsonHelperErrorHandlingTests.cs b/SW.Bitween.UnitTests/ScribanJsonHelperErrorHandlingTests.cs new file mode 100644 index 00000000..58eb6e1a --- /dev/null +++ b/SW.Bitween.UnitTests/ScribanJsonHelperErrorHandlingTests.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using static SW.Bitween.UnitTests.ScribanJsonTestHelper; + +namespace SW.Bitween.UnitTests; + +[TestClass] +public class ScribanJsonHelperErrorHandlingTests +{ + [TestMethod] + public void InvalidScribanTemplate_ThrowsTemplateParseError() + { + var input = "{\"x\":1}"; + var badTemplate = "{ \"x\": {{ for i in }} }"; + + var ex = Assert.ThrowsException(() => Render(badTemplate, input)); + + StringAssert.Contains(ex.Message, "Template parse error"); + } + + [TestMethod] + public void TemplateThatProducesInvalidJson_ThrowsExpectedError() + { + var input = "{\"x\":1}"; + var badTemplate = "{ \"x\": {{ x | json }}"; + + var ex = Assert.ThrowsException(() => Render(badTemplate, input)); + + StringAssert.Contains(ex.Message, "Template produced invalid JSON"); + } + + [TestMethod] + public void SpecialCharacters_AreEscapedCorrectly() + { + var input = "{\"text\":\"café \\\"quoted\\\"\"}"; + var template = "{ \"text\": {{ text | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"text\":\"café \\\"quoted\\\"\"}", output); + } +} diff --git a/SW.Bitween.UnitTests/ScribanJsonHelperLookupAndTypeRuleTests.cs b/SW.Bitween.UnitTests/ScribanJsonHelperLookupAndTypeRuleTests.cs new file mode 100644 index 00000000..479ec943 --- /dev/null +++ b/SW.Bitween.UnitTests/ScribanJsonHelperLookupAndTypeRuleTests.cs @@ -0,0 +1,158 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; +using static SW.Bitween.UnitTests.ScribanJsonTestHelper; + +namespace SW.Bitween.UnitTests; + +[TestClass] +public class ScribanJsonHelperLookupAndTypeRuleTests +{ + [TestMethod] + public void LookupMapping_NullFallback_WhenHit_ReturnsMappedValue() + { + var input = "{\"status\":\"A\"}"; + var template = "{ \"state\": {{ $__e = { \"A\": \"Active\", \"B\": \"Blocked\" }; $__e[status] | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"state\":\"Active\"}", output); + } + + [TestMethod] + public void LookupMapping_NullFallback_WhenMiss_ReturnsNull() + { + var input = "{\"status\":\"Z\"}"; + var template = "{ \"state\": {{ $__e = { \"A\": \"Active\" }; $__e[status] | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"state\":null}", output); + } + + [TestMethod] + public void LookupMapping_CustomFallback_WhenMiss_ReturnsCustomValue() + { + var input = "{\"status\":\"Z\"}"; + var template = "{ \"state\": {{ $__e = { \"A\": \"Active\" }; ($__e[status] ?? \"Unknown\") | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"state\":\"Unknown\"}", output); + } + + [TestMethod] + public void LookupMapping_RespectsNumberTargetValueType() + { + var input = "{\"code\":\"A\"}"; + var template = "{ \"mapped\": {{ $__e = { \"A\": 1, \"B\": 2 }; $__e[code] | json }} }"; + + var output = RenderObject(template, input); + + Assert.AreEqual(JTokenType.Integer, output["mapped"]?.Type); + Assert.AreEqual(1, output["mapped"]?.Value()); + } + + [TestMethod] + public void LookupMapping_RespectsBooleanTargetValueType() + { + var input = "{\"code\":\"A\"}"; + var template = "{ \"mapped\": {{ $__e = { \"A\": true, \"B\": false }; $__e[code] | json }} }"; + + var output = RenderObject(template, input); + + Assert.AreEqual(JTokenType.Boolean, output["mapped"]?.Type); + Assert.AreEqual(true, output["mapped"]?.Value()); + } + + [TestMethod] + public void TransformMapping_AppliesMathExpression() + { + var input = "{\"price\":100}"; + var template = "{ \"total\": {{ (price * 1.1) | json }} }"; + + var output = RenderObject(template, input); + + Assert.AreEqual(110, output["total"]?.Value()); + } + + [TestMethod] + public void TransformMapping_AppliesTypedRule_BoolToNumber() + { + var input = "{\"amount\":200}"; + var expr = "{{ $__t = (amount > 100); ($__t == null ? null : (($__t | object.typeof) == \"boolean\" ? ($__t ? 1 : 0) : (((($__t | object.typeof) == \"object\" || ($__t | object.typeof) == \"array\") ? null : ($__t | to_float)))) ) | json }}"; + + var output = RenderValue(expr, input); + + Assert.AreEqual(JTokenType.Integer, output.Type); + Assert.AreEqual(1, output.Value()); + } + + [TestMethod] + public void TransformMapping_AppliesTypedRule_BoolToString() + { + var input = "{\"amount\":200}"; + var expr = "{{ $__t = (amount > 100); ($__t == null ? null : (((($__t | object.typeof) == \"object\" || ($__t | object.typeof) == \"array\") ? null : (($__t | object.typeof) == \"boolean\" ? ($__t ? \"true\" : \"false\") : (\"\" + $__t)))) ) | json }}"; + + var output = RenderValue(expr, input); + + Assert.AreEqual(JTokenType.String, output.Type); + Assert.AreEqual("true", output.Value()); + } + + [TestMethod] + public void TypeRules_BoolToString_And_BoolToNumber() + { + var input = "{\"flag\":true,\"flag2\":false}"; + + var boolToString = RenderValue("{{ (flag == null ? null : (flag ? \"true\" : \"false\")) | json }}", input); + var boolToNumber = RenderValue("{{ (flag2 == null ? null : (flag2 ? 1 : 0)) | json }}", input); + + Assert.AreEqual("true", boolToString.Value()); + Assert.AreEqual(0, boolToNumber.Value()); + } + + [TestMethod] + public void TypeRules_StringToBool_IsNull() + { + var input = "{\"name\":\"x\"}"; + + var value = RenderValue("{{ null | json }}", input); + + Assert.AreEqual(JTokenType.Null, value.Type); + } + + [TestMethod] + public void TypeRules_NumberToBool_ZeroFalse_OneTrue_OtherNull() + { + var input = "{\"a\":0,\"b\":1,\"c\":9}"; + + var a = RenderValue("{{ (a == 0 ? false : (a == 1 ? true : null)) | json }}", input); + var b = RenderValue("{{ (b == 0 ? false : (b == 1 ? true : null)) | json }}", input); + var c = RenderValue("{{ (c == 0 ? false : (c == 1 ? true : null)) | json }}", input); + + Assert.AreEqual(false, a.Value()); + Assert.AreEqual(true, b.Value()); + Assert.AreEqual(JTokenType.Null, c.Type); + } + + [TestMethod] + public void TypeRules_StringToNumber_InvalidIsNull() + { + var input = "{\"a\":\"x\"}"; + + var value = RenderValue("{{ (a | to_float) | json }}", input); + + Assert.AreEqual(JTokenType.Null, value.Type); + } + + [TestMethod] + public void TypeRules_NumberToString_WrapsInQuotes() + { + var input = "{\"n\":42}"; + + var value = RenderValue("{{ (\"\" + n) | json }}", input); + + Assert.AreEqual(JTokenType.String, value.Type); + Assert.AreEqual("42", value.Value()); + } +} diff --git a/SW.Bitween.UnitTests/ScribanJsonHelperRootMappingTests.cs b/SW.Bitween.UnitTests/ScribanJsonHelperRootMappingTests.cs new file mode 100644 index 00000000..5f4dbc93 --- /dev/null +++ b/SW.Bitween.UnitTests/ScribanJsonHelperRootMappingTests.cs @@ -0,0 +1,162 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using static SW.Bitween.UnitTests.ScribanJsonTestHelper; + +namespace SW.Bitween.UnitTests; + +[TestClass] +public class ScribanJsonHelperRootMappingTests +{ + [TestMethod] + public void SourceMapping_RenamesFlatFields() + { + var input = "{\"orderId\":\"123\",\"status\":\"pending\"}"; + var template = "{ \"id\": {{ orderId | json }}, \"state\": {{ status | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"id\":\"123\",\"state\":\"pending\"}", output); + } + + [TestMethod] + public void SourceMapping_ReadsNestedField() + { + var input = "{\"customer\":{\"address\":{\"city\":\"Amman\"}}}"; + var template = "{ \"city\": {{ customer.address.city | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"city\":\"Amman\"}", output); + } + + [TestMethod] + public void SourceMapping_ExpandsDottedOutputPath() + { + var input = "{\"city\":\"Amman\"}"; + var template = "{ \"shipping.address.city\": {{ city | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"shipping\":{\"address\":{\"city\":\"Amman\"}}}", output); + } + + [TestMethod] + public void SourceMapping_ResolvesPascalCaseKeyAsIs() + { + var input = "{\"CustomerId\":\"C-001\"}"; + var template = "{ \"id\": {{ CustomerId | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"id\":\"C-001\"}", output); + } + + [TestMethod] + public void SourceMapping_ResolvesLowercaseAliasForPascalCaseKey() + { + var input = "{\"CustomerId\":\"C-001\"}"; + var template = "{ \"id\": {{ customerId | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"id\":\"C-001\"}", output); + } + + [TestMethod] + public void SourceMapping_MissingVariableDoesNotThrow() + { + var input = "{\"x\":1}"; + var template = "{ \"value\": {{ missingField | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"value\":null}", output); + } + + [TestMethod] + public void FixedMapping_HandlesStringNumberBoolAndNullLiterals() + { + var input = "{\"ignored\":true}"; + var template = "{ \"s\": \"pending\", \"n\": 5, \"b\": true, \"x\": null }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"s\":\"pending\",\"n\":5,\"b\":true,\"x\":null}", output); + } + + [TestMethod] + public void FixedMapping_AllowsEmbeddingSourceVariableInsideString() + { + var input = "{\"orderId\":\"C-001\"}"; + var template = "{ \"label\": \"Order-{{ orderId }}\" }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"label\":\"Order-C-001\"}", output); + } + + [TestMethod] + public void FixedMapping_AllowsEmbeddingMultipleSourceVariablesInsideString() + { + var input = "{\"first\":\"John\",\"last\":\"Doe\"}"; + var template = "{ \"name\": \"{{ first }} {{ last }}\" }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"name\":\"John Doe\"}", output); + } + + [TestMethod] + public void PartnerMapping_MapsStringValue() + { + var input = "{\"__partner__\":{\"apiKey\":\"abc\"}}"; + var template = "{ \"apiKey\": {{ __partner__?.apiKey | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"apiKey\":\"abc\"}", output); + } + + [TestMethod] + public void PartnerMapping_MissingKeyReturnsNull() + { + var input = "{\"__partner__\":{\"apiKey\":\"abc\"}}"; + var template = "{ \"region\": {{ __partner__?.region | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"region\":null}", output); + } + + [TestMethod] + public void PartnerAndGlobalTypedTargets_AreRepresentedAsNullTemplateLiteral() + { + var input = "{\"__partner__\":{\"flag\":true},\"__globals__\":{\"S\":{\"K\":\"1\"}}}"; + var template = "{ \"partnerBoolTarget\": null, \"globalNumberTarget\": null }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"partnerBoolTarget\":null,\"globalNumberTarget\":null}", output); + } + + [TestMethod] + public void GlobalMapping_MapsStringValue() + { + var input = "{\"__globals__\":{\"regionSet\":{\"europe\":\"EU\"}}}"; + var template = "{ \"region\": {{ __globals__?.regionSet[\"europe\"] | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"region\":\"EU\"}", output); + } + + [TestMethod] + public void GlobalMapping_MissingKeyReturnsNull() + { + var input = "{\"__globals__\":{\"regionSet\":{\"europe\":\"EU\"}}}"; + var template = "{ \"region\": {{ __globals__?.regionSet[\"unknown\"] | json }} }"; + + var output = Render(template, input); + + AssertJsonEquals("{\"region\":null}", output); + } +} diff --git a/SW.Bitween.UnitTests/ScribanJsonTestHelper.cs b/SW.Bitween.UnitTests/ScribanJsonTestHelper.cs new file mode 100644 index 00000000..d67dff5f --- /dev/null +++ b/SW.Bitween.UnitTests/ScribanJsonTestHelper.cs @@ -0,0 +1,31 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SW.Bitween.NativeAdapters.JsonMapper; + +namespace SW.Bitween.UnitTests; + +internal static class ScribanJsonTestHelper +{ + public static string Render(string template, string inputJson) => + ScribanJsonHelper.Render(template, inputJson); + + public static JObject RenderObject(string template, string inputJson) => + JObject.Parse(Render(template, inputJson)); + + public static JToken RenderValue(string valueExpression, string inputJson) + { + var template = "{ \"value\": " + valueExpression + " }"; + return RenderObject(template, inputJson)["value"]!; + } + + public static void AssertJsonEquals(string expectedJson, string actualJson) + { + var expected = JToken.Parse(expectedJson); + var actual = JToken.Parse(actualJson); + + Assert.IsTrue( + JToken.DeepEquals(expected, actual), + $"Expected JSON:\n{expected.ToString(Formatting.Indented)}\n\nActual JSON:\n{actual.ToString(Formatting.Indented)}"); + } +}