diff --git a/test/CloudNative.CloudEvents.UnitTests/NewtonsoftJson/JsonEventFormatterTest.cs b/test/CloudNative.CloudEvents.UnitTests/NewtonsoftJson/JsonEventFormatterTest.cs index 3f81d85..510bd87 100644 --- a/test/CloudNative.CloudEvents.UnitTests/NewtonsoftJson/JsonEventFormatterTest.cs +++ b/test/CloudNative.CloudEvents.UnitTests/NewtonsoftJson/JsonEventFormatterTest.cs @@ -20,6 +20,7 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests public class JsonEventFormatterTest { private static readonly ContentType s_jsonCloudEventContentType = new ContentType("application/cloudevents+json; charset=utf-8"); + private static readonly ContentType s_jsonCloudEventBatchContentType = new ContentType("application/cloudevents-batch+json; charset=utf-8"); private const string NonAsciiValue = "GBP=\u00a3"; /// @@ -96,7 +97,7 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests cloudEvent.Data = new { Text = "simple text" }; cloudEvent.DataContentType = "application/json"; JObject obj = EncodeAndParseStructured(cloudEvent); - JObject dataProperty = (JObject)obj["data"]; + JObject dataProperty = (JObject) obj["data"]; var asserter = new JTokenAsserter { { "Text", JTokenType.String, "simple text" } @@ -118,7 +119,7 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests var formatter = new JsonEventFormatter(serializer); byte[] encoded = formatter.EncodeStructuredModeMessage(cloudEvent, out _); JObject obj = ParseJson(encoded); - JObject dataProperty = (JObject)obj["data"]; + JObject dataProperty = (JObject) obj["data"]; var asserter = new JTokenAsserter { { "DateValue", JTokenType.String, "2021-02-19" } @@ -133,7 +134,7 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests cloudEvent.Data = new AttributedModel { AttributedProperty = "simple text" }; cloudEvent.DataContentType = "application/json"; JObject obj = EncodeAndParseStructured(cloudEvent); - JObject dataProperty = (JObject)obj["data"]; + JObject dataProperty = (JObject) obj["data"]; var asserter = new JTokenAsserter { { AttributedModel.JsonPropertyName, JTokenType.String, "simple text" } @@ -150,7 +151,7 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests JObject obj = EncodeAndParseStructured(cloudEvent); JToken data = obj["data"]; Assert.Equal(JTokenType.Integer, data.Type); - Assert.Equal(100, (int)data); + Assert.Equal(100, (int) data); } [Fact] @@ -172,7 +173,7 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests JObject obj = EncodeAndParseStructured(cloudEvent); var dataProperty = obj["data"]; Assert.Equal(JTokenType.String, dataProperty.Type); - Assert.Equal("some text", (string)dataProperty); + Assert.Equal("some text", (string) dataProperty); } // A text content type with bytes as data is serialized like any other bytes. @@ -186,7 +187,7 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests Assert.False(obj.ContainsKey("data")); var dataBase64 = obj["data_base64"]; Assert.Equal(JTokenType.String, dataBase64.Type); - Assert.Equal(SampleBinaryDataBase64, (string)dataBase64); + Assert.Equal(SampleBinaryDataBase64, (string) dataBase64); } [Fact] @@ -209,7 +210,7 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests Assert.False(obj.ContainsKey("data")); var dataBase64 = obj["data_base64"]; Assert.Equal(JTokenType.String, dataBase64.Type); - Assert.Equal(SampleBinaryDataBase64, (string)dataBase64); + Assert.Equal(SampleBinaryDataBase64, (string) dataBase64); } [Fact] @@ -345,6 +346,73 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests Assert.Throws(() => formatter.EncodeBinaryModeEventData(cloudEvent)); } + // Note: batch mode testing is restricted to the batch aspects; we assume that the + // per-CloudEvent implementation is shared with structured mode, so we rely on + // structured mode testing for things like custom serialization. + + [Fact] + public void EncodeBatchModeMessage_Empty() + { + var cloudEvents = new CloudEvent[0]; + var formatter = new JsonEventFormatter(); + byte[] bytes = formatter.EncodeBatchModeMessage(cloudEvents, out var contentType); + Assert.Equal("application/cloudevents-batch+json; charset=utf-8", contentType.ToString()); + var array = ParseJsonArray(bytes); + Assert.Empty(array); + } + + [Fact] + public void EncodeBatchModeMessage_TwoEvents() + { + var event1 = new CloudEvent().PopulateRequiredAttributes(); + event1.Id = "event1"; + event1.Data = "simple text"; + event1.DataContentType = "text/plain"; + + var event2 = new CloudEvent().PopulateRequiredAttributes(); + event2.Id = "event2"; + + var cloudEvents = new[] { event1, event2 }; + var formatter = new JsonEventFormatter(); + byte[] bytes = formatter.EncodeBatchModeMessage(cloudEvents, out var contentType); + Assert.Equal("application/cloudevents-batch+json; charset=utf-8", contentType.ToString()); + var array = ParseJsonArray(bytes); + Assert.Equal(2, array.Count); + + var asserter1 = new JTokenAsserter + { + { "specversion", JTokenType.String, "1.0" }, + { "id", JTokenType.String, event1.Id }, + { "type", JTokenType.String, event1.Type }, + { "source", JTokenType.String, "//test" }, + { "data", JTokenType.String, "simple text" }, + { "datacontenttype", JTokenType.String, event1.DataContentType } + }; + asserter1.AssertProperties((JObject) array[0], assertCount: true); + + var asserter2 = new JTokenAsserter + { + { "specversion", JTokenType.String, "1.0" }, + { "id", JTokenType.String, event2.Id }, + { "type", JTokenType.String, event2.Type }, + { "source", JTokenType.String, "//test" }, + }; + asserter2.AssertProperties((JObject) array[1], assertCount: true); + } + + [Fact] + public void EncodeBatchModeMessage_Invalid() + { + var formatter = new JsonEventFormatter(); + // Invalid CloudEvent + Assert.Throws(() => formatter.EncodeBatchModeMessage(new[] { new CloudEvent() }, out _)); + // Null argument + Assert.Throws(() => formatter.EncodeBatchModeMessage(null, out _)); + // Null value within the argument. Arguably this should throw ArgumentException instead of + // ArgumentNullException, but it's unlikely to cause confusion. + Assert.Throws(() => formatter.EncodeBatchModeMessage(new CloudEvent[1], out _)); + } + [Fact] public void DecodeStructuredModeMessage_NotJson() { @@ -502,10 +570,10 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests var formatter = new JsonEventFormatter(); var cloudEvent = formatter.DecodeStructuredModeMessage(bytes, s_jsonCloudEventContentType, AllTypesExtensions); Assert.Equal(SampleBinaryData, cloudEvent["binary"]); - Assert.True((bool)cloudEvent["boolean"]); + Assert.True((bool) cloudEvent["boolean"]); Assert.Equal(10, cloudEvent["integer"]); Assert.Equal("text", cloudEvent["string"]); - AssertTimestampsEqual(SampleTimestamp, (DateTimeOffset)cloudEvent["timestamp"]); + AssertTimestampsEqual(SampleTimestamp, (DateTimeOffset) cloudEvent["timestamp"]); Assert.Equal(SampleUri, cloudEvent["uri"]); Assert.Equal(SampleUriReference, cloudEvent["urireference"]); } @@ -626,7 +694,7 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests } obj["data"] = 10; var cloudEvent = DecodeStructuredModeMessage(obj); - var token = (JToken)cloudEvent.Data; + var token = (JToken) cloudEvent.Data; Assert.Equal(JTokenType.Integer, token.Type); Assert.Equal(10, (int) token); } @@ -712,6 +780,102 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests Assert.Same(bytes, data); } + [Fact] + public void DecodeBatchMode_NotArray() + { + var formatter = new JsonEventFormatter(); + var data = Encoding.UTF8.GetBytes(CreateMinimalValidJObject().ToString()); + Assert.Throws(() => formatter.DecodeBatchModeMessage(data, s_jsonCloudEventBatchContentType, extensionAttributes: null)); + } + + [Fact] + public void DecodeBatchMode_ArrayContainingNonObject() + { + var formatter = new JsonEventFormatter(); + var array = new JArray { CreateMinimalValidJObject(), "text" }; + var data = Encoding.UTF8.GetBytes(array.ToString()); + Assert.Throws(() => formatter.DecodeBatchModeMessage(data, s_jsonCloudEventBatchContentType, extensionAttributes: null)); + } + + [Fact] + public void DecodeBatchMode_Empty() + { + var cloudEvents = DecodeBatchModeMessage(new JArray()); + Assert.Empty(cloudEvents); + } + + [Fact] + public void DecodeBatchMode_Minimal() + { + var cloudEvents = DecodeBatchModeMessage(new JArray { CreateMinimalValidJObject() }); + var cloudEvent = Assert.Single(cloudEvents); + Assert.Equal("event-type", cloudEvent.Type); + Assert.Equal("event-id", cloudEvent.Id); + Assert.Equal(new Uri("//event-source", UriKind.RelativeOrAbsolute), cloudEvent.Source); + } + + // Just a single test for the code that parses asynchronously... the guts are all the same. + [Fact] + public async Task DecodeBatchModeMessageAsync_Minimal() + { + var obj = new JObject + { + ["specversion"] = "1.0", + ["type"] = "test-type", + ["id"] = "test-id", + ["source"] = SampleUriText, + }; + byte[] bytes = Encoding.UTF8.GetBytes(new JArray { obj }.ToString()); + var stream = new MemoryStream(bytes); + var formatter = new JsonEventFormatter(); + var cloudEvents = await formatter.DecodeBatchModeMessageAsync(stream, s_jsonCloudEventBatchContentType, null); + var cloudEvent = Assert.Single(cloudEvents); + Assert.Equal("test-type", cloudEvent.Type); + Assert.Equal("test-id", cloudEvent.Id); + Assert.Equal(SampleUri, cloudEvent.Source); + } + + + [Fact] + public void DecodeBatchMode_Multiple() + { + var array = new JArray + { + new JObject + { + ["specversion"] = "1.0", + ["type"] = "type1", + ["id"] = "event1", + ["source"] = "//event-source1", + ["data"] = "simple text", + ["datacontenttype"] = "text/plain" + }, + new JObject + { + ["specversion"] = "1.0", + ["type"] = "type2", + ["id"] = "event2", + ["source"] = "//event-source2" + }, + }; + var cloudEvents = DecodeBatchModeMessage(array); + Assert.Equal(2, cloudEvents.Count); + + var event1 = cloudEvents[0]; + Assert.Equal("type1", event1.Type); + Assert.Equal("event1", event1.Id); + Assert.Equal(new Uri("//event-source1", UriKind.RelativeOrAbsolute), event1.Source); + Assert.Equal("simple text", event1.Data); + Assert.Equal("text/plain", event1.DataContentType); + + var event2 = cloudEvents[1]; + Assert.Equal("type2", event2.Type); + Assert.Equal("event2", event2.Id); + Assert.Equal(new Uri("//event-source2", UriKind.RelativeOrAbsolute), event2.Source); + Assert.Null(event2.Data); + Assert.Null(event2.DataContentType); + } + private static object DecodeBinaryModeEventData(byte[] bytes, string contentType) { var cloudEvent = new CloudEvent().PopulateRequiredAttributes(); @@ -732,16 +896,24 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests /// /// Parses JSON as a JObject with settings that prevent any additional conversions. /// - internal static JObject ParseJson(byte[] data) + internal static JObject ParseJson(byte[] data) => ParseJsonImpl(data); + + /// + /// Parses JSON as a JArray with settings that prevent any additional conversions. + /// + internal static JArray ParseJsonArray(byte[] data) => ParseJsonImpl(data); + + private static T ParseJsonImpl(byte[] data) { string text = Encoding.UTF8.GetString(data); var serializer = new JsonSerializer { DateParseHandling = DateParseHandling.None }; - return serializer.Deserialize(new JsonTextReader(new StringReader(text))); + return serializer.Deserialize(new JsonTextReader(new StringReader(text))); } + /// /// Convenience method to format a CloudEvent with the default JsonEventFormatter in /// structured mode, then parse the result as a JObject. @@ -764,6 +936,17 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests return formatter.DecodeStructuredModeMessage(bytes, s_jsonCloudEventContentType, null); } + /// + /// Convenience method to serialize a JArray to bytes, then + /// decode it as a batch mode message with the default JsonEventFormatter and no extension attributes. + /// + private static IReadOnlyList DecodeBatchModeMessage(JArray array) + { + byte[] bytes = Encoding.UTF8.GetBytes(array.ToString()); + var formatter = new JsonEventFormatter(); + return formatter.DecodeBatchModeMessage(bytes, s_jsonCloudEventContentType, null); + } + private class JTokenAsserter : IEnumerable { private readonly List<(string name, JTokenType type, object value)> expectations = new List<(string, JTokenType, object)>(); diff --git a/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonEventFormatterTest.cs b/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonEventFormatterTest.cs index e481176..2b5f6db 100644 --- a/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonEventFormatterTest.cs +++ b/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonEventFormatterTest.cs @@ -18,12 +18,14 @@ using Xunit; using static CloudNative.CloudEvents.UnitTests.TestHelpers; // JObject is a really handy way of creating JSON which we can then parse with System.Text.Json using JObject = Newtonsoft.Json.Linq.JObject; +using JArray = Newtonsoft.Json.Linq.JArray; namespace CloudNative.CloudEvents.SystemTextJson.UnitTests { public class JsonEventFormatterTest { private static readonly ContentType s_jsonCloudEventContentType = new ContentType("application/cloudevents+json; charset=utf-8"); + private static readonly ContentType s_jsonCloudEventBatchContentType = new ContentType("application/cloudevents-batch+json; charset=utf-8"); private const string NonAsciiValue = "GBP=\u00a3"; /// @@ -353,6 +355,74 @@ namespace CloudNative.CloudEvents.SystemTextJson.UnitTests Assert.Throws(() => formatter.EncodeBinaryModeEventData(cloudEvent)); } + // Note: batch mode testing is restricted to the batch aspects; we assume that the + // per-CloudEvent implementation is shared with structured mode, so we rely on + // structured mode testing for things like custom serialization. + + [Fact] + public void EncodeBatchModeMessage_Empty() + { + var cloudEvents = new CloudEvent[0]; + var formatter = new JsonEventFormatter(); + byte[] bytes = formatter.EncodeBatchModeMessage(cloudEvents, out var contentType); + Assert.Equal("application/cloudevents-batch+json; charset=utf-8", contentType.ToString()); + var array = ParseJson(bytes); + Assert.Equal(JsonValueKind.Array, array.ValueKind); + Assert.Equal(0, array.GetArrayLength()); + } + + [Fact] + public void EncodeBatchModeMessage_TwoEvents() + { + var event1 = new CloudEvent().PopulateRequiredAttributes(); + event1.Id = "event1"; + event1.Data = "simple text"; + event1.DataContentType = "text/plain"; + + var event2 = new CloudEvent().PopulateRequiredAttributes(); + event2.Id = "event2"; + + var cloudEvents = new[] { event1, event2 }; + var formatter = new JsonEventFormatter(); + byte[] bytes = formatter.EncodeBatchModeMessage(cloudEvents, out var contentType); + Assert.Equal("application/cloudevents-batch+json; charset=utf-8", contentType.ToString()); + var array = ParseJson(bytes).EnumerateArray().ToList(); + Assert.Equal(2, array.Count); + + var asserter1 = new JsonElementAsserter + { + { "specversion", JsonValueKind.String, "1.0" }, + { "id", JsonValueKind.String, event1.Id }, + { "type", JsonValueKind.String, event1.Type }, + { "source", JsonValueKind.String, "//test" }, + { "data", JsonValueKind.String, "simple text" }, + { "datacontenttype", JsonValueKind.String, event1.DataContentType } + }; + asserter1.AssertProperties(array[0], assertCount: true); + + var asserter2 = new JsonElementAsserter + { + { "specversion", JsonValueKind.String, "1.0" }, + { "id", JsonValueKind.String, event2.Id }, + { "type", JsonValueKind.String, event2.Type }, + { "source", JsonValueKind.String, "//test" }, + }; + asserter2.AssertProperties(array[1], assertCount: true); + } + + [Fact] + public void EncodeBatchModeMessage_Invalid() + { + var formatter = new JsonEventFormatter(); + // Invalid CloudEvent + Assert.Throws(() => formatter.EncodeBatchModeMessage(new[] { new CloudEvent() }, out _)); + // Null argument + Assert.Throws(() => formatter.EncodeBatchModeMessage(null, out _)); + // Null value within the argument. Arguably this should throw ArgumentException instead of + // ArgumentNullException, but it's unlikely to cause confusion. + Assert.Throws(() => formatter.EncodeBatchModeMessage(new CloudEvent[1], out _)); + } + [Fact] public void DecodeStructuredModeMessage_NotJson() { @@ -731,6 +801,115 @@ namespace CloudNative.CloudEvents.SystemTextJson.UnitTests Assert.Same(bytes, data); } + [Fact] + public void DecodeBatchMode_NotArray() + { + var formatter = new JsonEventFormatter(); + var data = Encoding.UTF8.GetBytes(CreateMinimalValidJObject().ToString()); + Assert.Throws(() => formatter.DecodeBatchModeMessage(data, s_jsonCloudEventBatchContentType, extensionAttributes: null)); + } + + [Fact] + public void DecodeBatchMode_ArrayContainingNonObject() + { + var formatter = new JsonEventFormatter(); + var array = new JArray { CreateMinimalValidJObject(), "text" }; + var data = Encoding.UTF8.GetBytes(array.ToString()); + Assert.Throws(() => formatter.DecodeBatchModeMessage(data, s_jsonCloudEventBatchContentType, extensionAttributes: null)); + } + + [Fact] + public void DecodeBatchMode_Empty() + { + var cloudEvents = DecodeBatchModeMessage(new JArray()); + Assert.Empty(cloudEvents); + } + + [Fact] + public void DecodeBatchMode_Minimal() + { + var cloudEvents = DecodeBatchModeMessage(new JArray { CreateMinimalValidJObject() }); + var cloudEvent = Assert.Single(cloudEvents); + Assert.Equal("event-type", cloudEvent.Type); + Assert.Equal("event-id", cloudEvent.Id); + Assert.Equal(new Uri("//event-source", UriKind.RelativeOrAbsolute), cloudEvent.Source); + } + + [Fact] + public void DecodeBatchMode_Minimal_WithStream() + { + var array = new JArray { CreateMinimalValidJObject() }; + byte[] bytes = Encoding.UTF8.GetBytes(array.ToString()); + var formatter = new JsonEventFormatter(); + var cloudEvents = formatter.DecodeBatchModeMessage(new MemoryStream(bytes), s_jsonCloudEventBatchContentType, null); + var cloudEvent = Assert.Single(cloudEvents); + Assert.Equal("event-type", cloudEvent.Type); + Assert.Equal("event-id", cloudEvent.Id); + Assert.Equal(new Uri("//event-source", UriKind.RelativeOrAbsolute), cloudEvent.Source); + } + + // Just a single test for the code that parses asynchronously... the guts are all the same. + [Fact] + public async Task DecodeBatchModeMessageAsync_Minimal() + { + var obj = new JObject + { + ["specversion"] = "1.0", + ["type"] = "test-type", + ["id"] = "test-id", + ["source"] = SampleUriText, + }; + byte[] bytes = Encoding.UTF8.GetBytes(new JArray { obj }.ToString()); + var stream = new MemoryStream(bytes); + var formatter = new JsonEventFormatter(); + var cloudEvents = await formatter.DecodeBatchModeMessageAsync(stream, s_jsonCloudEventBatchContentType, null); + var cloudEvent = Assert.Single(cloudEvents); + Assert.Equal("test-type", cloudEvent.Type); + Assert.Equal("test-id", cloudEvent.Id); + Assert.Equal(SampleUri, cloudEvent.Source); + } + + + [Fact] + public void DecodeBatchMode_Multiple() + { + var array = new JArray + { + new JObject + { + ["specversion"] = "1.0", + ["type"] = "type1", + ["id"] = "event1", + ["source"] = "//event-source1", + ["data"] = "simple text", + ["datacontenttype"] = "text/plain" + }, + new JObject + { + ["specversion"] = "1.0", + ["type"] = "type2", + ["id"] = "event2", + ["source"] = "//event-source2" + }, + }; + var cloudEvents = DecodeBatchModeMessage(array); + Assert.Equal(2, cloudEvents.Count); + + var event1 = cloudEvents[0]; + Assert.Equal("type1", event1.Type); + Assert.Equal("event1", event1.Id); + Assert.Equal(new Uri("//event-source1", UriKind.RelativeOrAbsolute), event1.Source); + Assert.Equal("simple text", event1.Data); + Assert.Equal("text/plain", event1.DataContentType); + + var event2 = cloudEvents[1]; + Assert.Equal("type2", event2.Type); + Assert.Equal("event2", event2.Id); + Assert.Equal(new Uri("//event-source2", UriKind.RelativeOrAbsolute), event2.Source); + Assert.Null(event2.Data); + Assert.Null(event2.DataContentType); + } + private static object DecodeBinaryModeEventData(byte[] bytes, string contentType) { var cloudEvent = new CloudEvent().PopulateRequiredAttributes(); @@ -788,6 +967,17 @@ namespace CloudNative.CloudEvents.SystemTextJson.UnitTests return formatter.DecodeStructuredModeMessage(bytes, s_jsonCloudEventContentType, null); } + /// + /// Convenience method to serialize a JArray to bytes, then + /// decode it as a structured event with the default (System.Text.Json) JsonEventFormatter and no extension attributes. + /// + private static IReadOnlyList DecodeBatchModeMessage(Newtonsoft.Json.Linq.JArray array) + { + byte[] bytes = Encoding.UTF8.GetBytes(array.ToString()); + var formatter = new JsonEventFormatter(); + return formatter.DecodeBatchModeMessage(bytes, s_jsonCloudEventBatchContentType, null); + } + private class JsonElementAsserter : IEnumerable { private readonly List<(string name, JsonValueKind type, object value)> expectations = new List<(string, JsonValueKind, object)>(); diff --git a/test/CloudNative.CloudEvents.UnitTests/TestHelpers.cs b/test/CloudNative.CloudEvents.UnitTests/TestHelpers.cs index 1699ac0..fb8ff4c 100644 --- a/test/CloudNative.CloudEvents.UnitTests/TestHelpers.cs +++ b/test/CloudNative.CloudEvents.UnitTests/TestHelpers.cs @@ -84,7 +84,7 @@ namespace CloudNative.CloudEvents.UnitTests { cloudEvent.Id = "test-id"; cloudEvent.Source = new Uri("//test", UriKind.RelativeOrAbsolute); - cloudEvent.Type = "test-id"; + cloudEvent.Type = "test-type"; return cloudEvent; }