diff --git a/CloudEvents.sln b/CloudEvents.sln index 4aa8625..530452a 100644 --- a/CloudEvents.sln +++ b/CloudEvents.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29001.49 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.32112.339 MinimumVisualStudioVersion = 15.0.26124.0 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudNative.CloudEvents", "src\CloudNative.CloudEvents\CloudNative.CloudEvents.csproj", "{C5DC9F44-7C03-4A70-80EF-7A29696455EB}" EndProject @@ -35,7 +35,42 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudNative.CloudEvents.New EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudNative.CloudEvents.SystemTextJson", "src\CloudNative.CloudEvents.SystemTextJson\CloudNative.CloudEvents.SystemTextJson.csproj", "{FACB3EF2-F078-479A-A91C-719894CB66BF}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudNative.CloudEvents.Protobuf", "src\CloudNative.CloudEvents.Protobuf\CloudNative.CloudEvents.Protobuf.csproj", "{9D82AC2B-0075-4161-AE0E-4A6629C9FF2A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudNative.CloudEvents.Protobuf", "src\CloudNative.CloudEvents.Protobuf\CloudNative.CloudEvents.Protobuf.csproj", "{9D82AC2B-0075-4161-AE0E-4A6629C9FF2A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "conformance", "conformance", "{8CCC98B3-1776-49FF-96D6-947A9E5DFB0A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "format", "format", "{A5906FBA-D73A-4A09-8539-CB10D7B586AE}" + ProjectSection(SolutionItems) = preProject + conformance\format\README.md = conformance\format\README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "json", "json", "{D8055631-E6BB-4CD2-8162-F674D6D30E76}" + ProjectSection(SolutionItems) = preProject + conformance\format\json\invalid-batches.json = conformance\format\json\invalid-batches.json + conformance\format\json\invalid-events.json = conformance\format\json\invalid-events.json + conformance\format\json\README.md = conformance\format\json\README.md + conformance\format\json\valid-batches.json = conformance\format\json\valid-batches.json + conformance\format\json\valid-events.json = conformance\format\json\valid-events.json + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "protobuf", "protobuf", "{119AD438-878B-4383-BC9F-779F1605E711}" + ProjectSection(SolutionItems) = preProject + conformance\format\protobuf\conformance_tests.proto = conformance\format\protobuf\conformance_tests.proto + conformance\format\protobuf\invalid-batches.json = conformance\format\protobuf\invalid-batches.json + conformance\format\protobuf\invalid-events.json = conformance\format\protobuf\invalid-events.json + conformance\format\protobuf\README.md = conformance\format\protobuf\README.md + conformance\format\protobuf\valid-batches.json = conformance\format\protobuf\valid-batches.json + conformance\format\protobuf\valid-events.json = conformance\format\protobuf\valid-events.json + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "xml", "xml", "{4012C753-68DE-4737-936F-F5DBC485C51B}" + ProjectSection(SolutionItems) = preProject + conformance\format\xml\invalid-batches.xml = conformance\format\xml\invalid-batches.xml + conformance\format\xml\invalid-events.xml = conformance\format\xml\invalid-events.xml + conformance\format\xml\README.md = conformance\format\xml\README.md + conformance\format\xml\valid-batches.xml = conformance\format\xml\valid-batches.xml + conformance\format\xml\valid-events.xml = conformance\format\xml\valid-events.xml + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -207,6 +242,12 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {A5906FBA-D73A-4A09-8539-CB10D7B586AE} = {8CCC98B3-1776-49FF-96D6-947A9E5DFB0A} + {D8055631-E6BB-4CD2-8162-F674D6D30E76} = {A5906FBA-D73A-4A09-8539-CB10D7B586AE} + {119AD438-878B-4383-BC9F-779F1605E711} = {A5906FBA-D73A-4A09-8539-CB10D7B586AE} + {4012C753-68DE-4737-936F-F5DBC485C51B} = {A5906FBA-D73A-4A09-8539-CB10D7B586AE} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F77A454C-CC17-4AD6-823A-64E1A94FDA0A} EndGlobalSection diff --git a/generate_protos.sh b/generate_protos.sh index 64c892c..8c6bd73 100644 --- a/generate_protos.sh +++ b/generate_protos.sh @@ -63,5 +63,14 @@ $PROTOC \ --csharp_opt=file_extension=.g.cs \ test/CloudNative.CloudEvents.UnitTests/Protobuf/*.proto +# Conformance test protos +$PROTOC \ + -I tmp/include \ + -I tmp/cloudevents \ + -I conformance/format/protobuf \ + --csharp_out=test/CloudNative.CloudEvents.UnitTests/Protobuf \ + --csharp_opt=file_extension=.g.cs \ + conformance/format/protobuf/*.proto + echo "Generated code." rm -rf tmp diff --git a/test/CloudNative.CloudEvents.UnitTests/AspNetCore/HttpResponseExtensionsTest.cs b/test/CloudNative.CloudEvents.UnitTests/AspNetCore/HttpResponseExtensionsTest.cs index b511a22..38d5d24 100644 --- a/test/CloudNative.CloudEvents.UnitTests/AspNetCore/HttpResponseExtensionsTest.cs +++ b/test/CloudNative.CloudEvents.UnitTests/AspNetCore/HttpResponseExtensionsTest.cs @@ -95,7 +95,6 @@ namespace CloudNative.CloudEvents.AspNetCore.UnitTests var parsed = new JsonEventFormatter().DecodeStructuredModeMessage(content, new ContentType(response.ContentType), extensionAttributes: null); AssertCloudEventsEqual(cloudEvent, parsed); - Assert.Equal(cloudEvent.Data, parsed.Data); // We populate headers even though we don't strictly need to; let's validate that. Assert.Equal("1.0", response.Headers["ce-specversion"]); diff --git a/test/CloudNative.CloudEvents.UnitTests/ConformanceTestData/SampleBatches.cs b/test/CloudNative.CloudEvents.UnitTests/ConformanceTestData/SampleBatches.cs new file mode 100644 index 0000000..5903f2c --- /dev/null +++ b/test/CloudNative.CloudEvents.UnitTests/ConformanceTestData/SampleBatches.cs @@ -0,0 +1,42 @@ +// Copyright 2023 Cloud Native Foundation. +// Licensed under the Apache 2.0 license. +// See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace CloudNative.CloudEvents.UnitTests.ConformanceTestData; + +public static class SampleBatches +{ + private static ConcurrentDictionary> batchesById = new ConcurrentDictionary>(); + + private static readonly IReadOnlyList empty = Register("empty"); + private static readonly IReadOnlyList minimal = Register("minimal", SampleEvents.Minimal); + private static readonly IReadOnlyList minimal2 = Register("minimal2", SampleEvents.Minimal, SampleEvents.Minimal); + private static readonly IReadOnlyList minimalAndAllCore = Register("minimalAndAllCore", SampleEvents.Minimal, SampleEvents.AllCore); + private static readonly IReadOnlyList minimalAndAllExtensionTypes = + Register("minimalAndAllExtensionTypes", SampleEvents.Minimal, SampleEvents.AllExtensionTypes); + + internal static IReadOnlyList Empty => Clone(empty); + internal static IReadOnlyList Minimal => Clone(minimal); + internal static IReadOnlyList Minimal2 => Clone(minimal2); + internal static IReadOnlyList MinimalAndAllCore => Clone(minimalAndAllCore); + internal static IReadOnlyList MinimalAndAllExtensionTypes => Clone(minimalAndAllExtensionTypes); + + internal static IReadOnlyList FromId(string id) => batchesById.TryGetValue(id, out var batch) + ? Clone(batch) + : throw new ArgumentException($"No such sample batch: '{id}'"); + + private static IReadOnlyList Clone(IReadOnlyList cloudEvents) => + cloudEvents.Select(SampleEvents.Clone).ToList().AsReadOnly(); + + private static IReadOnlyList Register(string id, params CloudEvent[] cloudEvents) + { + var list = new List(cloudEvents).AsReadOnly(); + batchesById[id] = list; + return list; + } +} diff --git a/test/CloudNative.CloudEvents.UnitTests/ConformanceTestData/SampleEvents.cs b/test/CloudNative.CloudEvents.UnitTests/ConformanceTestData/SampleEvents.cs new file mode 100644 index 0000000..406056d --- /dev/null +++ b/test/CloudNative.CloudEvents.UnitTests/ConformanceTestData/SampleEvents.cs @@ -0,0 +1,123 @@ +// Copyright 2023 Cloud Native Foundation. +// Licensed under the Apache 2.0 license. +// See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace CloudNative.CloudEvents.UnitTests.ConformanceTestData; + +internal static class SampleEvents +{ + private static ConcurrentDictionary eventsById = new ConcurrentDictionary(); + + private static IReadOnlyList allExtensionAttributes = new List() + { + CloudEventAttribute.CreateExtension("extinteger", CloudEventAttributeType.Integer), + CloudEventAttribute.CreateExtension("extboolean", CloudEventAttributeType.Boolean), + CloudEventAttribute.CreateExtension("extstring", CloudEventAttributeType.String), + CloudEventAttribute.CreateExtension("exttimestamp", CloudEventAttributeType.Timestamp), + CloudEventAttribute.CreateExtension("exturi", CloudEventAttributeType.Uri), + CloudEventAttribute.CreateExtension("exturiref", CloudEventAttributeType.UriReference), + CloudEventAttribute.CreateExtension("extbinary", CloudEventAttributeType.Binary), + }.AsReadOnly(); + + private static readonly CloudEvent minimal = new CloudEvent + { + Id = "minimal", + Type = "io.cloudevents.test", + Source = new Uri("https://cloudevents.io") + }.Register(); + + private static readonly CloudEvent allCore = minimal.With(evt => + { + evt.Id = "allCore"; + evt.DataContentType = "text/plain"; + evt.DataSchema = new Uri("https://cloudevents.io/dataschema"); + evt.Subject = "tests"; + evt.Time = new DateTimeOffset(2018, 4, 5, 17, 31, 0, TimeSpan.Zero); + }).Register(); + + private static readonly CloudEvent minimalWithTime = minimal.With(evt => + { + evt.Id = "minimalWithTime"; + evt.Time = new DateTimeOffset(2018, 4, 5, 17, 31, 0, TimeSpan.Zero); + }).Register(); + + private static readonly CloudEvent minimalWithRelativeSource = minimal.With(evt => + { + evt.Id = "minimalWithRelativeSource"; + evt.Source = new Uri("#fragment", UriKind.RelativeOrAbsolute); + }).Register(); + + private static readonly CloudEvent simpleTextData = minimal.With(evt => + { + evt.Id = "simpleTextData"; + evt.Data = "Simple text"; + evt.DataContentType = "text/plain"; + }).Register(); + + private static readonly CloudEvent allExtensionTypes = minimal.WithSampleExtensionAttributes().With(evt => + { + evt.Id = "allExtensionTypes"; + + evt["extinteger"] = 10; + evt["extboolean"] = true; + evt["extstring"] = "text"; + evt["extbinary"] = new byte[] { 77, 97 }; + evt["exttimestamp"] = new DateTimeOffset(2023, 3, 31, 15, 12, 0, TimeSpan.Zero); + evt["exturi"] = new Uri("https://cloudevents.io"); + evt["exturiref"] = new Uri("//authority/path", UriKind.RelativeOrAbsolute); + }).Register(); + + internal static CloudEvent Minimal => Clone(minimal); + internal static CloudEvent AllCore => Clone(allCore); + internal static CloudEvent MinimalWithTime => Clone(minimalWithTime); + internal static CloudEvent MinimalWithRelativeSource => Clone(minimalWithRelativeSource); + internal static CloudEvent SimpleTextData => Clone(simpleTextData); + internal static CloudEvent AllExtensionTypes => Clone(allExtensionTypes); + internal static IReadOnlyList SampleExtensionAttributes => allExtensionAttributes; + + internal static CloudEvent FromId(string id) => eventsById.TryGetValue(id, out var evt) + ? Clone(evt) + : throw new ArgumentException($"No such sample event: '{id}'"); + + // TODO: Make this available somewhere else? + internal static CloudEvent Clone(CloudEvent evt) + { + var clone = new CloudEvent(evt.SpecVersion, evt.ExtensionAttributes); + foreach (var attr in evt.GetPopulatedAttributes()) + { + clone[attr.Key] = attr.Value; + } + // TODO: Deep copy where appropriate? + clone.Data = evt.Data; + return clone; + } + + private static CloudEvent With(this CloudEvent evt, Action action) + { + var clone = Clone(evt); + action(clone); + return clone; + } + + /// + /// Returns a clone of the given CloudEvent, with all attributes in + /// registered but without values. + /// + private static CloudEvent WithSampleExtensionAttributes(this CloudEvent evt) => evt.With(clone => + { + foreach (var attribute in allExtensionAttributes) + { + clone[attribute] = null; + } + }); + + private static CloudEvent Register(this CloudEvent evt) + { + eventsById[evt.Id ?? throw new InvalidOperationException("No ID in sample event")] = evt; + return evt; + } +} diff --git a/test/CloudNative.CloudEvents.UnitTests/ConformanceTestData/TestDataProvider.cs b/test/CloudNative.CloudEvents.UnitTests/ConformanceTestData/TestDataProvider.cs new file mode 100644 index 0000000..ff6e8ea --- /dev/null +++ b/test/CloudNative.CloudEvents.UnitTests/ConformanceTestData/TestDataProvider.cs @@ -0,0 +1,62 @@ +// Copyright 2023 Cloud Native Foundation. +// Licensed under the Apache 2.0 license. +// See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace CloudNative.CloudEvents.UnitTests.ConformanceTestData; + +internal class TestDataProvider +{ + private static readonly string ConformanceTestDataRoot = Path.Combine(FindRepoRoot(), "conformance", "format"); + + public static TestDataProvider Json { get; } = new TestDataProvider("json", "*.json"); + public static TestDataProvider Protobuf { get; } = new TestDataProvider("protobuf", "*.json"); + public static TestDataProvider Xml { get; } = new TestDataProvider("xml", "*.xml"); + + + private readonly string testDataDirectory; + private readonly string searchPattern; + + private TestDataProvider(string relativeDirectory, string searchPattern) + { + testDataDirectory = Path.Combine(ConformanceTestDataRoot, relativeDirectory); + this.searchPattern = searchPattern; + } + + public IEnumerable ListTestFiles() => Directory.EnumerateFiles(testDataDirectory, searchPattern); + + /// + /// Loads all tests, assuming multiple tests per file, to be loaded based on textual file content. + /// + /// The deserialized test file type. + /// The deserialized test type. + /// A function to parse the content of the file (provided as a string) to a test file. + /// A function to extract all the tests within the given test file. + public IReadOnlyList LoadTests(Func fileParser, Func> testExtractor) => + ListTestFiles() + .Select(file => fileParser(File.ReadAllText(file))) + .SelectMany(testExtractor) + .ToList() + .AsReadOnly(); + + private static string FindRepoRoot() + { + var currentDirectory = Path.GetFullPath("."); + var directory = new DirectoryInfo(currentDirectory); + while (directory != null && + (!File.Exists(Path.Combine(directory.FullName, "LICENSE")) + || !File.Exists(Path.Combine(directory.FullName, "CloudEvents.sln")))) + { + directory = directory.Parent; + } + if (directory == null) + { + throw new Exception("Unable to determine root directory. Please run within the sdk-csharp repository."); + } + return directory.FullName; + } +} diff --git a/test/CloudNative.CloudEvents.UnitTests/Http/HttpListenerExtensionsTest.cs b/test/CloudNative.CloudEvents.UnitTests/Http/HttpListenerExtensionsTest.cs index 862dbee..812b629 100644 --- a/test/CloudNative.CloudEvents.UnitTests/Http/HttpListenerExtensionsTest.cs +++ b/test/CloudNative.CloudEvents.UnitTests/Http/HttpListenerExtensionsTest.cs @@ -260,7 +260,6 @@ namespace CloudNative.CloudEvents.Http.UnitTests var parsed = new JsonEventFormatter().DecodeStructuredModeMessage(bytes, MimeUtilities.ToContentType(content.Headers.ContentType), extensionAttributes: null); AssertCloudEventsEqual(cloudEvent, parsed); - Assert.Equal(cloudEvent.Data, parsed.Data); // We populate headers even though we don't strictly need to; let's validate that. Assert.Equal("1.0", response.Headers.GetValues("ce-specversion").Single()); diff --git a/test/CloudNative.CloudEvents.UnitTests/NewtonsoftJson/ConformanceTest.cs b/test/CloudNative.CloudEvents.UnitTests/NewtonsoftJson/ConformanceTest.cs new file mode 100644 index 0000000..4c251f4 --- /dev/null +++ b/test/CloudNative.CloudEvents.UnitTests/NewtonsoftJson/ConformanceTest.cs @@ -0,0 +1,74 @@ +// Copyright 2023 Cloud Native Foundation. +// Licensed under the Apache 2.0 license. +// See LICENSE file in the project root for full license information. + +using CloudNative.CloudEvents.UnitTests; +using CloudNative.CloudEvents.UnitTests.ConformanceTestData; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Xunit; + +namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests; + +public class ConformanceTest +{ + private static readonly IReadOnlyList allTests = + TestDataProvider.Json.LoadTests(ConformanceTestFile.FromJson, file => file.Tests); + + private static JsonConformanceTest GetTestById(string id) => allTests.Single(test => test.Id == id); + private static IEnumerable SelectTestIds(ConformanceTestType type) => + allTests + .Where(test => test.TestType == type) + .Select(test => new object[] { test.Id }); + + public static IEnumerable ValidEventTestIds => SelectTestIds(ConformanceTestType.ValidSingleEvent); + public static IEnumerable InvalidEventTestIds => SelectTestIds(ConformanceTestType.InvalidSingleEvent); + public static IEnumerable ValidBatchTestIds => SelectTestIds(ConformanceTestType.ValidBatch); + public static IEnumerable InvalidBatchTestIds => SelectTestIds(ConformanceTestType.InvalidBatch); + + [Theory, MemberData(nameof(ValidEventTestIds))] + public void ValidEvent(string testId) + { + var test = GetTestById(testId); + CloudEvent expected = SampleEvents.FromId(test.SampleId); + var extensions = test.SampleExtensionAttributes ? SampleEvents.SampleExtensionAttributes : null; + CloudEvent actual = new JsonEventFormatter().ConvertFromJObject(test.Event, extensions); + TestHelpers.AssertCloudEventsEqual(expected, actual, TestHelpers.InstantOnlyTimestampComparer); + } + + [Theory, MemberData(nameof(InvalidEventTestIds))] + public void InvalidEvent(string testId) + { + var test = GetTestById(testId); + var formatter = new JsonEventFormatter(); + var extensions = test.SampleExtensionAttributes ? SampleEvents.SampleExtensionAttributes : null; + Assert.Throws(() => formatter.ConvertFromJObject(test.Event, extensions)); + } + + [Theory, MemberData(nameof(ValidBatchTestIds))] + public void ValidBatch(string testId) + { + var test = GetTestById(testId); + IReadOnlyList expected = SampleBatches.FromId(test.SampleId); + var extensions = test.SampleExtensionAttributes ? SampleEvents.SampleExtensionAttributes : null; + // We don't have a convenience method for batches, so serialize the array back to JSON. + var json = test.Batch.ToString(); + var body = Encoding.UTF8.GetBytes(json); + IReadOnlyList actual = new JsonEventFormatter().DecodeBatchModeMessage(body, contentType: null, extensions); + TestHelpers.AssertBatchesEqual(expected, actual, TestHelpers.InstantOnlyTimestampComparer); + } + + [Theory, MemberData(nameof(InvalidBatchTestIds))] + public void InvalidBatch(string testId) + { + var test = GetTestById(testId); + var formatter = new JsonEventFormatter(); + var extensions = test.SampleExtensionAttributes ? SampleEvents.SampleExtensionAttributes : null; + // We don't have a convenience method for batches, so serialize the array back to JSON. + var json = test.Batch.ToString(); + var body = Encoding.UTF8.GetBytes(json); + Assert.Throws(() => formatter.DecodeBatchModeMessage(body, contentType: null, extensions)); + } +} diff --git a/test/CloudNative.CloudEvents.UnitTests/NewtonsoftJson/ConformanceTestFile.cs b/test/CloudNative.CloudEvents.UnitTests/NewtonsoftJson/ConformanceTestFile.cs new file mode 100644 index 0000000..3f58315 --- /dev/null +++ b/test/CloudNative.CloudEvents.UnitTests/NewtonsoftJson/ConformanceTestFile.cs @@ -0,0 +1,51 @@ +// Copyright 2023 Cloud Native Foundation. +// Licensed under the Apache 2.0 license. +// See LICENSE file in the project root for full license information. + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; + +namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests; + +#nullable disable + +public class ConformanceTestFile +{ + private static readonly JsonSerializerSettings serializerSeettings = new() { DateParseHandling = DateParseHandling.None }; + + public ConformanceTestType? TestType { get; set; } + public List Tests { get; } = new List(); + + public static ConformanceTestFile FromJson(string json) + { + var testFile = JsonConvert.DeserializeObject(json, serializerSeettings) ?? throw new InvalidOperationException(); + foreach (var test in testFile.Tests) + { + test.TestType ??= testFile.TestType; + } + return testFile; + } +} + +public class JsonConformanceTest +{ + public string Id { get; set; } + public string Description { get; set; } + public ConformanceTestType? TestType { get; set; } + public string SampleId { get; set; } + public JObject Event { get; set; } + public JArray Batch { get; set; } + public bool RoundTrip { get; set; } + public bool SampleExtensionAttributes { get; set; } + public bool ExtensionConstraints { get; set; } +} + +public enum ConformanceTestType +{ + ValidSingleEvent, + ValidBatch, + InvalidSingleEvent, + InvalidBatch +} \ No newline at end of file diff --git a/test/CloudNative.CloudEvents.UnitTests/Protobuf/Conformance.cs b/test/CloudNative.CloudEvents.UnitTests/Protobuf/Conformance.cs new file mode 100644 index 0000000..5556141 --- /dev/null +++ b/test/CloudNative.CloudEvents.UnitTests/Protobuf/Conformance.cs @@ -0,0 +1,69 @@ +// Copyright 2023 Cloud Native Foundation. +// Licensed under the Apache 2.0 license. +// See LICENSE file in the project root for full license information. + +using CloudNative.CloudEvents.UnitTests; +using CloudNative.CloudEvents.UnitTests.ConformanceTestData; +using Google.Protobuf; +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace CloudNative.CloudEvents.Protobuf.UnitTests; + +public class Conformance +{ + private static readonly IReadOnlyList allTests = + TestDataProvider.Protobuf.LoadTests(ConformanceTestFile.FromJson, file => file.Tests); + + private static ConformanceTest GetTestById(string id) => allTests.Single(test => test.Id == id); + private static IEnumerable SelectTestIds(ConformanceTest.EventOneofCase eventCase) => + allTests + .Where(test => test.EventCase == eventCase) + .Select(test => new object[] { test.Id }); + + public static IEnumerable ValidEventTestIds => SelectTestIds(ConformanceTest.EventOneofCase.ValidSingle); + public static IEnumerable InvalidEventTestIds => SelectTestIds(ConformanceTest.EventOneofCase.InvalidSingle); + public static IEnumerable ValidBatchTestIds => SelectTestIds(ConformanceTest.EventOneofCase.ValidBatch); + public static IEnumerable InvalidBatchTestIds => SelectTestIds(ConformanceTest.EventOneofCase.InvalidBatch); + + [Theory, MemberData(nameof(ValidEventTestIds))] + public void ValidEvent(string testId) + { + var test = GetTestById(testId); + CloudEvent expected = SampleEvents.FromId(test.SampleId); + CloudEvent actual = new ProtobufEventFormatter().ConvertFromProto(test.ValidSingle, null); + TestHelpers.AssertCloudEventsEqual(expected, actual, TestHelpers.InstantOnlyTimestampComparer); + } + + [Theory, MemberData(nameof(InvalidEventTestIds))] + public void InvalidEvent(string testId) + { + var test = GetTestById(testId); + var formatter = new ProtobufEventFormatter(); + Assert.Throws(() => formatter.ConvertFromProto(test.InvalidSingle, null)); + } + + [Theory, MemberData(nameof(ValidBatchTestIds))] + public void ValidBatch(string testId) + { + var test = GetTestById(testId); + IReadOnlyList expected = SampleBatches.FromId(test.SampleId); + + // We don't have a convenience method for batches, so serialize batch back to binary. + var body = test.ValidBatch.ToByteArray(); + IReadOnlyList actual = new ProtobufEventFormatter().DecodeBatchModeMessage(body, null, null); + TestHelpers.AssertBatchesEqual(expected, actual, TestHelpers.InstantOnlyTimestampComparer); + } + + [Theory, MemberData(nameof(InvalidBatchTestIds))] + public void InvalidBatch(string testId) + { + var test = GetTestById(testId); + var formatter = new ProtobufEventFormatter(); + // We don't have a convenience method for batches, so serialize batch back to binary. + var body = test.InvalidBatch.ToByteArray(); + Assert.Throws(() => formatter.DecodeBatchModeMessage(body, null, null)); + } +} diff --git a/test/CloudNative.CloudEvents.UnitTests/Protobuf/ConformanceTestFile.cs b/test/CloudNative.CloudEvents.UnitTests/Protobuf/ConformanceTestFile.cs new file mode 100644 index 0000000..20cd427 --- /dev/null +++ b/test/CloudNative.CloudEvents.UnitTests/Protobuf/ConformanceTestFile.cs @@ -0,0 +1,17 @@ +// Copyright 2023 Cloud Native Foundation. +// Licensed under the Apache 2.0 license. +// See LICENSE file in the project root for full license information. + +using Google.Protobuf; +using Google.Protobuf.Reflection; + +namespace CloudNative.CloudEvents.Protobuf.UnitTests; + +public partial class ConformanceTestFile +{ + private static readonly JsonParser jsonParser = + new(JsonParser.Settings.Default.WithTypeRegistry(TypeRegistry.FromFiles(ConformanceTestsReflection.Descriptor))); + + internal static ConformanceTestFile FromJson(string json) => + jsonParser.Parse(json); +} diff --git a/test/CloudNative.CloudEvents.UnitTests/Protobuf/ConformanceTests.g.cs b/test/CloudNative.CloudEvents.UnitTests/Protobuf/ConformanceTests.g.cs new file mode 100644 index 0000000..cb85f46 --- /dev/null +++ b/test/CloudNative.CloudEvents.UnitTests/Protobuf/ConformanceTests.g.cs @@ -0,0 +1,964 @@ +// +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: conformance_tests.proto +// +#pragma warning disable 1591, 0612, 3021, 8981 +#region Designer generated code + +using pb = global::Google.Protobuf; +using pbc = global::Google.Protobuf.Collections; +using pbr = global::Google.Protobuf.Reflection; +using scg = global::System.Collections.Generic; +namespace CloudNative.CloudEvents.Protobuf.UnitTests { + + /// Holder for reflection information generated from conformance_tests.proto + public static partial class ConformanceTestsReflection { + + #region Descriptor + /// File descriptor for conformance_tests.proto + public static pbr::FileDescriptor Descriptor { + get { return descriptor; } + } + private static pbr::FileDescriptor descriptor; + + static ConformanceTestsReflection() { + byte[] descriptorData = global::System.Convert.FromBase64String( + string.Concat( + "Chdjb25mb3JtYW5jZV90ZXN0cy5wcm90bxIRaW8uY2xvdWRldmVudHMudjEa", + "EWNsb3VkZXZlbnRzLnByb3RvIkgKE0NvbmZvcm1hbmNlVGVzdEZpbGUSMQoF", + "dGVzdHMYASADKAsyIi5pby5jbG91ZGV2ZW50cy52MS5Db25mb3JtYW5jZVRl", + "c3QitgIKD0NvbmZvcm1hbmNlVGVzdBIKCgJpZBgBIAEoCRITCgtkZXNjcmlw", + "dGlvbhgCIAEoCRIRCglzYW1wbGVfaWQYAyABKAkSNQoMdmFsaWRfc2luZ2xl", + "GAQgASgLMh0uaW8uY2xvdWRldmVudHMudjEuQ2xvdWRFdmVudEgAEjkKC3Zh", + "bGlkX2JhdGNoGAUgASgLMiIuaW8uY2xvdWRldmVudHMudjEuQ2xvdWRFdmVu", + "dEJhdGNoSAASNwoOaW52YWxpZF9zaW5nbGUYBiABKAsyHS5pby5jbG91ZGV2", + "ZW50cy52MS5DbG91ZEV2ZW50SAASOwoNaW52YWxpZF9iYXRjaBgHIAEoCzIi", + "LmlvLmNsb3VkZXZlbnRzLnYxLkNsb3VkRXZlbnRCYXRjaEgAQgcKBWV2ZW50", + "IioKGkNvbmZvcm1hbmNlVGVzdE1lc3NhZ2VEYXRhEgwKBHRleHQYASABKAlC", + "LaoCKkNsb3VkTmF0aXZlLkNsb3VkRXZlbnRzLlByb3RvYnVmLlVuaXRUZXN0", + "c2IGcHJvdG8z")); + descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, + new pbr::FileDescriptor[] { global::CloudNative.CloudEvents.V1.CloudeventsReflection.Descriptor, }, + new pbr::GeneratedClrTypeInfo(null, null, new pbr::GeneratedClrTypeInfo[] { + new pbr::GeneratedClrTypeInfo(typeof(global::CloudNative.CloudEvents.Protobuf.UnitTests.ConformanceTestFile), global::CloudNative.CloudEvents.Protobuf.UnitTests.ConformanceTestFile.Parser, new[]{ "Tests" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::CloudNative.CloudEvents.Protobuf.UnitTests.ConformanceTest), global::CloudNative.CloudEvents.Protobuf.UnitTests.ConformanceTest.Parser, new[]{ "Id", "Description", "SampleId", "ValidSingle", "ValidBatch", "InvalidSingle", "InvalidBatch" }, new[]{ "Event" }, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::CloudNative.CloudEvents.Protobuf.UnitTests.ConformanceTestMessageData), global::CloudNative.CloudEvents.Protobuf.UnitTests.ConformanceTestMessageData.Parser, new[]{ "Text" }, null, null, null, null) + })); + } + #endregion + + } + #region Messages + /// + /// A simple container for conformance tests. + /// + public sealed partial class ConformanceTestFile : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new ConformanceTestFile()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::CloudNative.CloudEvents.Protobuf.UnitTests.ConformanceTestsReflection.Descriptor.MessageTypes[0]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ConformanceTestFile() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ConformanceTestFile(ConformanceTestFile other) : this() { + tests_ = other.tests_.Clone(); + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ConformanceTestFile Clone() { + return new ConformanceTestFile(this); + } + + /// Field number for the "tests" field. + public const int TestsFieldNumber = 1; + private static readonly pb::FieldCodec _repeated_tests_codec + = pb::FieldCodec.ForMessage(10, global::CloudNative.CloudEvents.Protobuf.UnitTests.ConformanceTest.Parser); + private readonly pbc::RepeatedField tests_ = new pbc::RepeatedField(); + /// + /// The tests within this file. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Tests { + get { return tests_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as ConformanceTestFile); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(ConformanceTestFile other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if(!tests_.Equals(other.tests_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + hash ^= tests_.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + tests_.WriteTo(output, _repeated_tests_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + tests_.WriteTo(ref output, _repeated_tests_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + size += tests_.CalculateSize(_repeated_tests_codec); + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(ConformanceTestFile other) { + if (other == null) { + return; + } + tests_.Add(other.tests_); + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + tests_.AddEntriesFrom(input, _repeated_tests_codec); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + tests_.AddEntriesFrom(ref input, _repeated_tests_codec); + break; + } + } + } + } + #endif + + } + + /// + /// A single test in the conformance test suite. + /// + public sealed partial class ConformanceTest : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new ConformanceTest()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::CloudNative.CloudEvents.Protobuf.UnitTests.ConformanceTestsReflection.Descriptor.MessageTypes[1]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ConformanceTest() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ConformanceTest(ConformanceTest other) : this() { + id_ = other.id_; + description_ = other.description_; + sampleId_ = other.sampleId_; + switch (other.EventCase) { + case EventOneofCase.ValidSingle: + ValidSingle = other.ValidSingle.Clone(); + break; + case EventOneofCase.ValidBatch: + ValidBatch = other.ValidBatch.Clone(); + break; + case EventOneofCase.InvalidSingle: + InvalidSingle = other.InvalidSingle.Clone(); + break; + case EventOneofCase.InvalidBatch: + InvalidBatch = other.InvalidBatch.Clone(); + break; + } + + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ConformanceTest Clone() { + return new ConformanceTest(this); + } + + /// Field number for the "id" field. + public const int IdFieldNumber = 1; + private string id_ = ""; + /// + /// The ID of the test; must be unique across all protobuf conformance tests. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Id { + get { return id_; } + set { + id_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "description" field. + public const int DescriptionFieldNumber = 2; + private string description_ = ""; + /// + /// The description of the test. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Description { + get { return description_; } + set { + description_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "sample_id" field. + public const int SampleIdFieldNumber = 3; + private string sampleId_ = ""; + /// + /// For valid tests, the ID of the well-known sample event/batch that + /// this test data should be equivalent to. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string SampleId { + get { return sampleId_; } + set { + sampleId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "valid_single" field. + public const int ValidSingleFieldNumber = 4; + /// + /// A single event that should be converted to an in-memory representation without error. + /// sample_id indicates the sample event that the result should be equivalent to. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::CloudNative.CloudEvents.V1.CloudEvent ValidSingle { + get { return eventCase_ == EventOneofCase.ValidSingle ? (global::CloudNative.CloudEvents.V1.CloudEvent) event_ : null; } + set { + event_ = value; + eventCase_ = value == null ? EventOneofCase.None : EventOneofCase.ValidSingle; + } + } + + /// Field number for the "valid_batch" field. + public const int ValidBatchFieldNumber = 5; + /// + /// A batch of events that should be converted to an in-memory representation without error. + /// sample_id indicates the sample batch that the result should be equivalent to. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::CloudNative.CloudEvents.V1.CloudEventBatch ValidBatch { + get { return eventCase_ == EventOneofCase.ValidBatch ? (global::CloudNative.CloudEvents.V1.CloudEventBatch) event_ : null; } + set { + event_ = value; + eventCase_ = value == null ? EventOneofCase.None : EventOneofCase.ValidBatch; + } + } + + /// Field number for the "invalid_single" field. + public const int InvalidSingleFieldNumber = 6; + /// + /// A single event that should be rejected when converted to an in-memory representation. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::CloudNative.CloudEvents.V1.CloudEvent InvalidSingle { + get { return eventCase_ == EventOneofCase.InvalidSingle ? (global::CloudNative.CloudEvents.V1.CloudEvent) event_ : null; } + set { + event_ = value; + eventCase_ = value == null ? EventOneofCase.None : EventOneofCase.InvalidSingle; + } + } + + /// Field number for the "invalid_batch" field. + public const int InvalidBatchFieldNumber = 7; + /// + /// A batch of events that should be rejected when converted to an in-memory representation. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::CloudNative.CloudEvents.V1.CloudEventBatch InvalidBatch { + get { return eventCase_ == EventOneofCase.InvalidBatch ? (global::CloudNative.CloudEvents.V1.CloudEventBatch) event_ : null; } + set { + event_ = value; + eventCase_ = value == null ? EventOneofCase.None : EventOneofCase.InvalidBatch; + } + } + + private object event_; + /// Enum of possible cases for the "event" oneof. + public enum EventOneofCase { + None = 0, + ValidSingle = 4, + ValidBatch = 5, + InvalidSingle = 6, + InvalidBatch = 7, + } + private EventOneofCase eventCase_ = EventOneofCase.None; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public EventOneofCase EventCase { + get { return eventCase_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void ClearEvent() { + eventCase_ = EventOneofCase.None; + event_ = null; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as ConformanceTest); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(ConformanceTest other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (Id != other.Id) return false; + if (Description != other.Description) return false; + if (SampleId != other.SampleId) return false; + if (!object.Equals(ValidSingle, other.ValidSingle)) return false; + if (!object.Equals(ValidBatch, other.ValidBatch)) return false; + if (!object.Equals(InvalidSingle, other.InvalidSingle)) return false; + if (!object.Equals(InvalidBatch, other.InvalidBatch)) return false; + if (EventCase != other.EventCase) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (Id.Length != 0) hash ^= Id.GetHashCode(); + if (Description.Length != 0) hash ^= Description.GetHashCode(); + if (SampleId.Length != 0) hash ^= SampleId.GetHashCode(); + if (eventCase_ == EventOneofCase.ValidSingle) hash ^= ValidSingle.GetHashCode(); + if (eventCase_ == EventOneofCase.ValidBatch) hash ^= ValidBatch.GetHashCode(); + if (eventCase_ == EventOneofCase.InvalidSingle) hash ^= InvalidSingle.GetHashCode(); + if (eventCase_ == EventOneofCase.InvalidBatch) hash ^= InvalidBatch.GetHashCode(); + hash ^= (int) eventCase_; + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (Id.Length != 0) { + output.WriteRawTag(10); + output.WriteString(Id); + } + if (Description.Length != 0) { + output.WriteRawTag(18); + output.WriteString(Description); + } + if (SampleId.Length != 0) { + output.WriteRawTag(26); + output.WriteString(SampleId); + } + if (eventCase_ == EventOneofCase.ValidSingle) { + output.WriteRawTag(34); + output.WriteMessage(ValidSingle); + } + if (eventCase_ == EventOneofCase.ValidBatch) { + output.WriteRawTag(42); + output.WriteMessage(ValidBatch); + } + if (eventCase_ == EventOneofCase.InvalidSingle) { + output.WriteRawTag(50); + output.WriteMessage(InvalidSingle); + } + if (eventCase_ == EventOneofCase.InvalidBatch) { + output.WriteRawTag(58); + output.WriteMessage(InvalidBatch); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (Id.Length != 0) { + output.WriteRawTag(10); + output.WriteString(Id); + } + if (Description.Length != 0) { + output.WriteRawTag(18); + output.WriteString(Description); + } + if (SampleId.Length != 0) { + output.WriteRawTag(26); + output.WriteString(SampleId); + } + if (eventCase_ == EventOneofCase.ValidSingle) { + output.WriteRawTag(34); + output.WriteMessage(ValidSingle); + } + if (eventCase_ == EventOneofCase.ValidBatch) { + output.WriteRawTag(42); + output.WriteMessage(ValidBatch); + } + if (eventCase_ == EventOneofCase.InvalidSingle) { + output.WriteRawTag(50); + output.WriteMessage(InvalidSingle); + } + if (eventCase_ == EventOneofCase.InvalidBatch) { + output.WriteRawTag(58); + output.WriteMessage(InvalidBatch); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (Id.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Id); + } + if (Description.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Description); + } + if (SampleId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(SampleId); + } + if (eventCase_ == EventOneofCase.ValidSingle) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(ValidSingle); + } + if (eventCase_ == EventOneofCase.ValidBatch) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(ValidBatch); + } + if (eventCase_ == EventOneofCase.InvalidSingle) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(InvalidSingle); + } + if (eventCase_ == EventOneofCase.InvalidBatch) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(InvalidBatch); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(ConformanceTest other) { + if (other == null) { + return; + } + if (other.Id.Length != 0) { + Id = other.Id; + } + if (other.Description.Length != 0) { + Description = other.Description; + } + if (other.SampleId.Length != 0) { + SampleId = other.SampleId; + } + switch (other.EventCase) { + case EventOneofCase.ValidSingle: + if (ValidSingle == null) { + ValidSingle = new global::CloudNative.CloudEvents.V1.CloudEvent(); + } + ValidSingle.MergeFrom(other.ValidSingle); + break; + case EventOneofCase.ValidBatch: + if (ValidBatch == null) { + ValidBatch = new global::CloudNative.CloudEvents.V1.CloudEventBatch(); + } + ValidBatch.MergeFrom(other.ValidBatch); + break; + case EventOneofCase.InvalidSingle: + if (InvalidSingle == null) { + InvalidSingle = new global::CloudNative.CloudEvents.V1.CloudEvent(); + } + InvalidSingle.MergeFrom(other.InvalidSingle); + break; + case EventOneofCase.InvalidBatch: + if (InvalidBatch == null) { + InvalidBatch = new global::CloudNative.CloudEvents.V1.CloudEventBatch(); + } + InvalidBatch.MergeFrom(other.InvalidBatch); + break; + } + + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + Id = input.ReadString(); + break; + } + case 18: { + Description = input.ReadString(); + break; + } + case 26: { + SampleId = input.ReadString(); + break; + } + case 34: { + global::CloudNative.CloudEvents.V1.CloudEvent subBuilder = new global::CloudNative.CloudEvents.V1.CloudEvent(); + if (eventCase_ == EventOneofCase.ValidSingle) { + subBuilder.MergeFrom(ValidSingle); + } + input.ReadMessage(subBuilder); + ValidSingle = subBuilder; + break; + } + case 42: { + global::CloudNative.CloudEvents.V1.CloudEventBatch subBuilder = new global::CloudNative.CloudEvents.V1.CloudEventBatch(); + if (eventCase_ == EventOneofCase.ValidBatch) { + subBuilder.MergeFrom(ValidBatch); + } + input.ReadMessage(subBuilder); + ValidBatch = subBuilder; + break; + } + case 50: { + global::CloudNative.CloudEvents.V1.CloudEvent subBuilder = new global::CloudNative.CloudEvents.V1.CloudEvent(); + if (eventCase_ == EventOneofCase.InvalidSingle) { + subBuilder.MergeFrom(InvalidSingle); + } + input.ReadMessage(subBuilder); + InvalidSingle = subBuilder; + break; + } + case 58: { + global::CloudNative.CloudEvents.V1.CloudEventBatch subBuilder = new global::CloudNative.CloudEvents.V1.CloudEventBatch(); + if (eventCase_ == EventOneofCase.InvalidBatch) { + subBuilder.MergeFrom(InvalidBatch); + } + input.ReadMessage(subBuilder); + InvalidBatch = subBuilder; + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + Id = input.ReadString(); + break; + } + case 18: { + Description = input.ReadString(); + break; + } + case 26: { + SampleId = input.ReadString(); + break; + } + case 34: { + global::CloudNative.CloudEvents.V1.CloudEvent subBuilder = new global::CloudNative.CloudEvents.V1.CloudEvent(); + if (eventCase_ == EventOneofCase.ValidSingle) { + subBuilder.MergeFrom(ValidSingle); + } + input.ReadMessage(subBuilder); + ValidSingle = subBuilder; + break; + } + case 42: { + global::CloudNative.CloudEvents.V1.CloudEventBatch subBuilder = new global::CloudNative.CloudEvents.V1.CloudEventBatch(); + if (eventCase_ == EventOneofCase.ValidBatch) { + subBuilder.MergeFrom(ValidBatch); + } + input.ReadMessage(subBuilder); + ValidBatch = subBuilder; + break; + } + case 50: { + global::CloudNative.CloudEvents.V1.CloudEvent subBuilder = new global::CloudNative.CloudEvents.V1.CloudEvent(); + if (eventCase_ == EventOneofCase.InvalidSingle) { + subBuilder.MergeFrom(InvalidSingle); + } + input.ReadMessage(subBuilder); + InvalidSingle = subBuilder; + break; + } + case 58: { + global::CloudNative.CloudEvents.V1.CloudEventBatch subBuilder = new global::CloudNative.CloudEvents.V1.CloudEventBatch(); + if (eventCase_ == EventOneofCase.InvalidBatch) { + subBuilder.MergeFrom(InvalidBatch); + } + input.ReadMessage(subBuilder); + InvalidBatch = subBuilder; + break; + } + } + } + } + #endif + + } + + /// + /// A sample message for tests using CloudEvent.proto_data. + /// + public sealed partial class ConformanceTestMessageData : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new ConformanceTestMessageData()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::CloudNative.CloudEvents.Protobuf.UnitTests.ConformanceTestsReflection.Descriptor.MessageTypes[2]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ConformanceTestMessageData() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ConformanceTestMessageData(ConformanceTestMessageData other) : this() { + text_ = other.text_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ConformanceTestMessageData Clone() { + return new ConformanceTestMessageData(this); + } + + /// Field number for the "text" field. + public const int TextFieldNumber = 1; + private string text_ = ""; + /// + /// Just some text data. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Text { + get { return text_; } + set { + text_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as ConformanceTestMessageData); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(ConformanceTestMessageData other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (Text != other.Text) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (Text.Length != 0) hash ^= Text.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (Text.Length != 0) { + output.WriteRawTag(10); + output.WriteString(Text); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (Text.Length != 0) { + output.WriteRawTag(10); + output.WriteString(Text); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (Text.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Text); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(ConformanceTestMessageData other) { + if (other == null) { + return; + } + if (other.Text.Length != 0) { + Text = other.Text; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + Text = input.ReadString(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + Text = input.ReadString(); + break; + } + } + } + } + #endif + + } + + #endregion + +} + +#endregion Designer generated code diff --git a/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/ConformanceTest.cs b/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/ConformanceTest.cs new file mode 100644 index 0000000..8cac6f9 --- /dev/null +++ b/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/ConformanceTest.cs @@ -0,0 +1,78 @@ +// Copyright 2023 Cloud Native Foundation. +// Licensed under the Apache 2.0 license. +// See LICENSE file in the project root for full license information. + +using CloudNative.CloudEvents.UnitTests; +using CloudNative.CloudEvents.UnitTests.ConformanceTestData; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Xunit; + +namespace CloudNative.CloudEvents.SystemTextJson.UnitTests; + +public class ConformanceTest +{ + private static readonly IReadOnlyList allTests = + TestDataProvider.Json.LoadTests(ConformanceTestFile.FromJson, file => file.Tests); + + private static JsonConformanceTest GetTestById(string id) => allTests.Single(test => test.Id == id); + private static IEnumerable SelectTestIds(ConformanceTestType type) => + allTests + .Where(test => test.TestType == type) + .Select(test => new object[] { test.Id }); + + public static IEnumerable ValidEventTestIds => SelectTestIds(ConformanceTestType.ValidSingleEvent); + public static IEnumerable InvalidEventTestIds => SelectTestIds(ConformanceTestType.InvalidSingleEvent); + public static IEnumerable ValidBatchTestIds => SelectTestIds(ConformanceTestType.ValidBatch); + public static IEnumerable InvalidBatchTestIds => SelectTestIds(ConformanceTestType.InvalidBatch); + + [Theory, MemberData(nameof(ValidEventTestIds))] + public void ValidEvent(string testId) + { + var test = GetTestById(testId); + CloudEvent expected = SampleEvents.FromId(test.SampleId); + var extensions = test.SampleExtensionAttributes ? SampleEvents.SampleExtensionAttributes : null; + CloudEvent actual = new JsonEventFormatter().ConvertFromJsonElement(test.Event, extensions); + TestHelpers.AssertCloudEventsEqual(expected, actual, TestHelpers.InstantOnlyTimestampComparer); + } + + [Theory, MemberData(nameof(InvalidEventTestIds))] + public void InvalidEvent(string testId) + { + var test = GetTestById(testId); + var formatter = new JsonEventFormatter(); + var extensions = test.SampleExtensionAttributes ? SampleEvents.SampleExtensionAttributes : null; + // Hmm... we throw FormatException in some cases, when ArgumentException would be better. + // Changing that would be "somewhat breaking"... it's unclear how much we should worry. + Assert.ThrowsAny(() => formatter.ConvertFromJsonElement(test.Event, extensions)); + } + + [Theory, MemberData(nameof(ValidBatchTestIds))] + public void ValidBatch(string testId) + { + var test = GetTestById(testId); + IReadOnlyList expected = SampleBatches.FromId(test.SampleId); + var extensions = test.SampleExtensionAttributes ? SampleEvents.SampleExtensionAttributes : null; + // We don't have a convenience method for batches, so serialize the array back to JSON. + var json = test.Batch.ToString(); + var body = Encoding.UTF8.GetBytes(json); + IReadOnlyList actual = new JsonEventFormatter().DecodeBatchModeMessage(body, contentType: null, extensions); + TestHelpers.AssertBatchesEqual(expected, actual, TestHelpers.InstantOnlyTimestampComparer); + } + + [Theory, MemberData(nameof(InvalidBatchTestIds))] + public void InvalidBatch(string testId) + { + var test = GetTestById(testId); + var formatter = new JsonEventFormatter(); + var extensions = test.SampleExtensionAttributes ? SampleEvents.SampleExtensionAttributes : null; + // We don't have a convenience method for batches, so serialize the array back to JSON. + var json = test.Batch.ToString(); + var body = Encoding.UTF8.GetBytes(json); + // Hmm... we throw FormatException in some cases, when ArgumentException would be better. + // Changing that would be "somewhat breaking"... it's unclear how much we should worry. + Assert.ThrowsAny(() => formatter.DecodeBatchModeMessage(body, contentType: null, extensions)); + } +} diff --git a/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/ConformanceTestFile.cs b/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/ConformanceTestFile.cs new file mode 100644 index 0000000..9964181 --- /dev/null +++ b/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/ConformanceTestFile.cs @@ -0,0 +1,64 @@ +// Copyright 2023 Cloud Native Foundation. +// Licensed under the Apache 2.0 license. +// See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace CloudNative.CloudEvents.SystemTextJson.UnitTests; + +#nullable disable + +public class ConformanceTestFile +{ + private static readonly JsonSerializerOptions serializerOptions = new() { Converters = { new JsonStringEnumConverter() } }; + + [JsonPropertyName("testType")] + public ConformanceTestType? TestType { get; set; } + + // Note: we need a setter here; System.Text.Json doesn't support adding to an existing collection. + // See https://github.com/dotnet/runtime/issues/30258 + [JsonPropertyName("tests")] + public List Tests { get; set; } = new List(); + + public static ConformanceTestFile FromJson(string json) + { + var testFile = JsonSerializer.Deserialize(json, serializerOptions) ?? throw new InvalidOperationException(); + foreach (var test in testFile.Tests) + { + test.TestType ??= testFile.TestType; + } + return testFile; + } +} + +public class JsonConformanceTest +{ + [JsonPropertyName("id")] + public string Id { get; set; } + [JsonPropertyName("description")] + public string Description { get; set; } + [JsonPropertyName("testType")] + public ConformanceTestType? TestType { get; set; } + [JsonPropertyName("sampleId")] + public string SampleId { get; set; } + [JsonPropertyName("event")] + public JsonElement Event { get; set; } + [JsonPropertyName("batch")] + public JsonArray Batch { get; set; } + [JsonPropertyName("sampleExtensionAttributes")] + public bool SampleExtensionAttributes { get; set; } + [JsonPropertyName("extensionConstraints")] + public bool ExtensionConstraints { get; set; } +} + +public enum ConformanceTestType +{ + ValidSingleEvent, + ValidBatch, + InvalidSingleEvent, + InvalidBatch +} \ No newline at end of file diff --git a/test/CloudNative.CloudEvents.UnitTests/TestHelpers.cs b/test/CloudNative.CloudEvents.UnitTests/TestHelpers.cs index 309744c..da1d230 100644 --- a/test/CloudNative.CloudEvents.UnitTests/TestHelpers.cs +++ b/test/CloudNative.CloudEvents.UnitTests/TestHelpers.cs @@ -4,7 +4,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.IO; using System.Linq; using Xunit; @@ -15,6 +17,9 @@ namespace CloudNative.CloudEvents.UnitTests /// internal static class TestHelpers { + internal static IEqualityComparer InstantOnlyTimestampComparer => EqualityComparer.Default; + internal static IEqualityComparer StrictTimestampComparer => StrictTimestampComparerImpl.Instance; + internal static CloudEventAttribute[] EmptyExtensionArray { get; } = new CloudEventAttribute[0]; internal static IEnumerable EmptyExtensionSequence { get; } = new List().AsReadOnly(); @@ -44,7 +49,7 @@ namespace CloudNative.CloudEvents.UnitTests /// /// The base64 representation of . /// - internal static string SampleBinaryDataBase64 { get; } = Convert.ToBase64String(SampleBinaryData); + internal static string SampleBinaryDataBase64 { get; } = Convert.ToBase64String(SampleBinaryData); // AQID /// /// Arbitrary timestamp to be used for testing. @@ -154,8 +159,12 @@ namespace CloudNative.CloudEvents.UnitTests } // TODO: Use this more widely - internal static void AssertCloudEventsEqual(CloudEvent expected, CloudEvent actual) + // TODO: Document handling of timestamps, and potentially parameterize it. + internal static void AssertCloudEventsEqual(CloudEvent expected, CloudEvent actual, + IEqualityComparer? timestampComparer = null, + IEqualityComparer? dataComparer = null) { + timestampComparer ??= StrictTimestampComparer; Assert.Equal(expected.SpecVersion, actual.SpecVersion); var expectedAttributes = expected.GetPopulatedAttributes().ToList(); var actualAttributes = actual.GetPopulatedAttributes().ToList(); @@ -166,18 +175,62 @@ namespace CloudNative.CloudEvents.UnitTests var actualAttribute = actualAttributes.FirstOrDefault(actual => actual.Key.Name == expectedAttribute.Key.Name); Assert.NotNull(actualAttribute.Key); - Assert.Equal(actualAttribute.Key.Type, expectedAttribute.Key.Type); - Assert.Equal(actualAttribute.Value, expectedAttribute.Value); + Assert.Equal(expectedAttribute.Key.Type, actualAttribute.Key.Type); + if (expectedAttribute.Value is DateTimeOffset expectedDto && + actualAttribute.Value is DateTimeOffset actualDto) + { + Assert.Equal(expectedDto, actualDto, timestampComparer); + } + else + { + Assert.Equal(expectedAttribute.Value, actualAttribute.Value); + } } + Assert.Equal(expected.Data, actual.Data, dataComparer ?? EqualityComparer.Default); } - internal static void AssertBatchesEqual(IReadOnlyList expectedBatch, IReadOnlyList actualBatch) + internal static void AssertBatchesEqual(IReadOnlyList expectedBatch, IReadOnlyList actualBatch, + IEqualityComparer? timestampComparer = null, + IEqualityComparer? dataComparer = null) { Assert.Equal(expectedBatch.Count, actualBatch.Count); foreach (var pair in expectedBatch.Zip(actualBatch, (x, y) => (x, y))) { - AssertCloudEventsEqual(pair.x, pair.y); + AssertCloudEventsEqual(pair.x, pair.y, timestampComparer, dataComparer); } } + + /// + /// Loads the resource with the given name, copying it into a MemoryStream. + /// (That's often easier to work with when debugging.) + /// + internal static MemoryStream LoadResource(string resource) + { + using var stream = typeof(TestHelpers).Assembly.GetManifestResourceStream(resource); + if (stream is null) + { + throw new ArgumentException($"Resource {resource} is missing. Known resources: {string.Join(", ", typeof(TestHelpers).Assembly.GetManifestResourceNames())}"); + } + var output = new MemoryStream(); + stream.CopyTo(output); + output.Position = 0; + return output; + } + + private class StrictTimestampComparerImpl : IEqualityComparer + { + internal static StrictTimestampComparerImpl Instance { get; } = new StrictTimestampComparerImpl(); + + private StrictTimestampComparerImpl() + { + } + + public bool Equals(DateTimeOffset x, DateTimeOffset y) => + x.UtcDateTime == y.UtcDateTime && + x.Offset == y.Offset; + + public int GetHashCode([DisallowNull] DateTimeOffset obj) => + obj.UtcDateTime.GetHashCode() ^ obj.Offset.GetHashCode(); + } } }