using System.Text.Json; using Google.Protobuf; using Google.Protobuf.Reflection; using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; namespace ZB.MOM.WW.MxGateway.Tests.Contracts; public sealed class ClientProtoInputTests { /// /// Guards the published client descriptor set against silent staleness. Every message, /// field, enum, enum value, service, and method compiled into the in-process contract /// (which the build regenerates from the current .proto sources) must appear in the /// committed protoset. A missing symbol means the descriptor was not regenerated after a /// proto change; run scripts/publish-client-proto-inputs.ps1 and commit the /// refreshed protoset. /// The check is semantic (symbol presence) rather than byte-wise, so it is independent of protoc /// version and does not require protoc on the test runner. /// [Fact] public void Descriptor_ContainsEveryContractSymbol() { DirectoryInfo repositoryRoot = FindRepositoryRoot(); string descriptorPath = Path.Combine( repositoryRoot.FullName, "clients", "proto", "descriptors", "mxaccessgw-client-v1.protoset"); Assert.True(File.Exists(descriptorPath), $"Expected descriptor set '{descriptorPath}' to exist."); FileDescriptorSet descriptorSet = FileDescriptorSet.Parser.ParseFrom(File.ReadAllBytes(descriptorPath)); HashSet publishedMessages = new(StringComparer.Ordinal); HashSet publishedFields = new(StringComparer.Ordinal); HashSet publishedEnums = new(StringComparer.Ordinal); HashSet publishedServices = new(StringComparer.Ordinal); foreach (FileDescriptorProto file in descriptorSet.File) { foreach (DescriptorProto message in file.MessageType) { CollectPublishedSymbols(file.Package, message, publishedMessages, publishedFields, publishedEnums); } foreach (EnumDescriptorProto enumType in file.EnumType) { CollectPublishedEnumSymbols(file.Package, enumType, publishedEnums); } foreach (ServiceDescriptorProto service in file.Service) { string serviceFullName = string.IsNullOrEmpty(file.Package) ? service.Name : file.Package + "." + service.Name; publishedServices.Add(serviceFullName); foreach (MethodDescriptorProto method in service.Method) { publishedServices.Add(serviceFullName + "/" + method.Name); } } } List missing = []; FileDescriptor[] contractFiles = [ MxaccessGatewayReflection.Descriptor, MxaccessWorkerReflection.Descriptor, GalaxyRepositoryReflection.Descriptor, ]; foreach (FileDescriptor file in contractFiles) { foreach (MessageDescriptor message in file.MessageTypes) { CollectMissingContractSymbols(message, publishedMessages, publishedFields, publishedEnums, missing); } foreach (EnumDescriptor enumType in file.EnumTypes) { CollectMissingEnumSymbols(enumType, publishedEnums, missing); } foreach (ServiceDescriptor service in file.Services) { if (!publishedServices.Contains(service.FullName)) { missing.Add(service.FullName); } foreach (MethodDescriptor method in service.Methods) { string key = service.FullName + "/" + method.Name; if (!publishedServices.Contains(key)) { missing.Add(key); } } } } Assert.True( missing.Count == 0, "Published client descriptor is stale; regenerate with scripts/publish-client-proto-inputs.ps1 and commit. " + "Missing symbols: " + string.Join(", ", missing)); } private static void CollectPublishedSymbols( string package, DescriptorProto message, HashSet messages, HashSet fields, HashSet enums) { string fullName = string.IsNullOrEmpty(package) ? message.Name : package + "." + message.Name; messages.Add(fullName); foreach (FieldDescriptorProto field in message.Field) { fields.Add(fullName + "/" + field.Name); } foreach (DescriptorProto nested in message.NestedType) { CollectPublishedSymbols(fullName, nested, messages, fields, enums); } foreach (EnumDescriptorProto enumType in message.EnumType) { CollectPublishedEnumSymbols(fullName, enumType, enums); } } private static void CollectPublishedEnumSymbols( string containingScope, EnumDescriptorProto enumType, HashSet enums) { string enumFullName = string.IsNullOrEmpty(containingScope) ? enumType.Name : containingScope + "." + enumType.Name; enums.Add(enumFullName); foreach (EnumValueDescriptorProto value in enumType.Value) { enums.Add(enumFullName + "/" + value.Name); } } private static void CollectMissingContractSymbols( MessageDescriptor message, HashSet publishedMessages, HashSet publishedFields, HashSet publishedEnums, List missing) { if (!publishedMessages.Contains(message.FullName)) { missing.Add(message.FullName); } foreach (FieldDescriptor field in message.Fields.InDeclarationOrder()) { string key = message.FullName + "/" + field.Name; if (!publishedFields.Contains(key)) { missing.Add(key); } } foreach (MessageDescriptor nested in message.NestedTypes) { CollectMissingContractSymbols(nested, publishedMessages, publishedFields, publishedEnums, missing); } foreach (EnumDescriptor enumType in message.EnumTypes) { CollectMissingEnumSymbols(enumType, publishedEnums, missing); } } private static void CollectMissingEnumSymbols( EnumDescriptor enumType, HashSet publishedEnums, List missing) { if (!publishedEnums.Contains(enumType.FullName)) { missing.Add(enumType.FullName); } foreach (EnumValueDescriptor value in enumType.Values) { string key = enumType.FullName + "/" + value.Name; if (!publishedEnums.Contains(key)) { missing.Add(key); } } } /// Verifies that the proto inputs manifest declares current protocol versions and existing source files. [Fact] public void Manifest_DeclaresCurrentProtocolVersionsAndExistingInputs() { DirectoryInfo repositoryRoot = FindRepositoryRoot(); string manifestPath = Path.Combine(repositoryRoot.FullName, "clients", "proto", "proto-inputs.json"); using JsonDocument manifest = JsonDocument.Parse(File.ReadAllText(manifestPath)); JsonElement root = manifest.RootElement; Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32()); Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, root.GetProperty("gatewayProtocolVersion").GetUInt32()); Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, root.GetProperty("workerProtocolVersion").GetUInt32()); string protoRoot = Path.Combine(repositoryRoot.FullName, root.GetProperty("protoRoot").GetString()!); foreach (JsonElement sourceFile in root.GetProperty("sourceFiles").EnumerateArray()) { string sourcePath = Path.Combine(protoRoot, sourceFile.GetProperty("path").GetString()!); Assert.True(File.Exists(sourcePath), $"Expected proto source file '{sourcePath}' to exist."); } foreach (JsonProperty output in root.GetProperty("generatedOutputs").EnumerateObject()) { string outputPath = Path.Combine(repositoryRoot.FullName, output.Value.GetString()!); Assert.True(Directory.Exists(outputPath), $"Expected generated output directory '{outputPath}' to exist."); } } /// Verifies that the OpenSessionReply fixture parses with the current contract version. [Fact] public void OpenSessionReplyFixture_ParsesWithCurrentContract() { OpenSessionReply reply = ParseFixture( "open-session-reply.ok.json", OpenSessionReply.Parser); Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, reply.GatewayProtocolVersion); Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, reply.WorkerProtocolVersion); Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); } /// Verifies that the RegisterCommand fixture parses with the current contract version. [Fact] public void RegisterCommandRequestFixture_ParsesWithCurrentContract() { MxCommandRequest request = ParseFixture( "register-command-request.json", MxCommandRequest.Parser); Assert.Equal(MxCommandKind.Register, request.Command.Kind); Assert.Equal("fixture-client", request.Command.Register.ClientName); } /// Verifies that the OnDataChange event fixture parses with the current contract version. [Fact] public void OnDataChangeEventFixture_ParsesWithCurrentContract() { MxEvent gatewayEvent = ParseFixture( "on-data-change-event.json", MxEvent.Parser); Assert.Equal(MxEventFamily.OnDataChange, gatewayEvent.Family); Assert.Equal(1ul, gatewayEvent.WorkerSequence); Assert.Equal(MxDataType.Integer, gatewayEvent.Value.DataType); Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, gatewayEvent.BodyCase); } private static T ParseFixture( string fixtureName, MessageParser parser) where T : IMessage { DirectoryInfo repositoryRoot = FindRepositoryRoot(); string fixturePath = Path.Combine(repositoryRoot.FullName, "clients", "proto", "fixtures", "golden", fixtureName); return parser.ParseJson(File.ReadAllText(fixturePath)); } private static DirectoryInfo FindRepositoryRoot() { DirectoryInfo? current = new(AppContext.BaseDirectory); while (current is not null) { if (File.Exists(Path.Combine(current.FullName, "CLAUDE.md")) && Directory.Exists(Path.Combine(current.FullName, "src")) && Directory.Exists(Path.Combine(current.FullName, "clients"))) { return current; } current = current.Parent; } throw new DirectoryNotFoundException("Could not locate the repository root from the test output directory."); } }