diff --git a/src/Service.Tests/GraphQLBuilder/GraphQLNamingTests.cs b/src/Service.Tests/GraphQLBuilder/GraphQLNamingTests.cs
new file mode 100644
index 0000000000..f77ded73bf
--- /dev/null
+++ b/src/Service.Tests/GraphQLBuilder/GraphQLNamingTests.cs
@@ -0,0 +1,169 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Service.GraphQLBuilder;
+using Azure.DataApiBuilder.Service.Tests.GraphQLBuilder.Helpers;
+using HotChocolate.Language;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.GraphQLBuilder
+{
+ ///
+ /// Unit tests for the pure naming helpers in .
+ ///
+ [TestClass]
+ public class GraphQLNamingTests
+ {
+ [DataTestMethod]
+ [DataRow("Book", "Book", DisplayName = "Valid name is unchanged")]
+ [DataRow("1Book", "Book", DisplayName = "Illegal leading digit is stripped")]
+ [DataRow("_Book", "Book", DisplayName = "Illegal leading underscore is stripped")]
+ [DataRow("Bo-ok", "Book", DisplayName = "Invalid symbol removed")]
+ public void SanitizeGraphQLName_SingleSegment(string input, string expected)
+ {
+ string[] segments = GraphQLNaming.SanitizeGraphQLName(input);
+ Assert.AreEqual(1, segments.Length);
+ Assert.AreEqual(expected, segments[0]);
+ }
+
+ [TestMethod]
+ public void SanitizeGraphQLName_RemovesSpacesAndInvalidSymbols()
+ {
+ // Spaces and non-alphanumeric symbols are removed, yielding a single segment.
+ string[] segments = GraphQLNaming.SanitizeGraphQLName("my table!");
+ CollectionAssert.AreEqual(new[] { "mytable" }, segments);
+ }
+
+ [DataTestMethod]
+ [DataRow("Book", false)]
+ [DataRow("book", false)]
+ [DataRow("1book", true)]
+ [DataRow("_book", true)]
+ public void ViolatesNamePrefixRequirements(string name, bool expected)
+ {
+ Assert.AreEqual(expected, GraphQLNaming.ViolatesNamePrefixRequirements(name));
+ }
+
+ [DataTestMethod]
+ [DataRow("Book", false)]
+ [DataRow("Book_1", false)]
+ [DataRow("Bo-ok", true)]
+ [DataRow("my table", true)]
+ public void ViolatesNameRequirements(string name, bool expected)
+ {
+ Assert.AreEqual(expected, GraphQLNaming.ViolatesNameRequirements(name));
+ }
+
+ [DataTestMethod]
+ [DataRow("__type", true)]
+ [DataRow("__schema", true)]
+ [DataRow("type", false)]
+ [DataRow("_type", false)]
+ public void IsIntrospectionField(string fieldName, bool expected)
+ {
+ Assert.AreEqual(expected, GraphQLNaming.IsIntrospectionField(fieldName));
+ }
+
+ [TestMethod]
+ public void GetDefinedSingularName_WithSingular_ReturnsSingular()
+ {
+ Entity entity = GraphQLTestHelpers.GenerateEntityWithSingularPlural("Book", "Books");
+ Assert.AreEqual("Book", GraphQLNaming.GetDefinedSingularName("Book", entity));
+ }
+
+ [TestMethod]
+ public void GetDefinedSingularName_WithoutSingular_Throws()
+ {
+ Entity entity = GraphQLTestHelpers.GenerateEmptyEntity();
+ Assert.ThrowsException(
+ () => GraphQLNaming.GetDefinedSingularName("Book", entity));
+ }
+
+ [TestMethod]
+ public void GetDefinedPluralName_WithPlural_ReturnsPlural()
+ {
+ Entity entity = GraphQLTestHelpers.GenerateEntityWithSingularPlural("Book", "Books");
+ Assert.AreEqual("Books", GraphQLNaming.GetDefinedPluralName("Book", entity));
+ }
+
+ [TestMethod]
+ public void GetDefinedPluralName_WithoutPlural_Throws()
+ {
+ Entity entity = GraphQLTestHelpers.GenerateEntityWithStringType("Book");
+ Assert.ThrowsException(
+ () => GraphQLNaming.GetDefinedPluralName("Book", entity));
+ }
+
+ [DataTestMethod]
+ [DataRow("Book", "book", DisplayName = "First char lowercased")]
+ [DataRow("my table", "mytable", DisplayName = "Spaces removed, first char lowercased")]
+ [DataRow("book", "book", DisplayName = "Already camel-case unchanged")]
+ public void FormatNameForField(string input, string expected)
+ {
+ Assert.AreEqual(expected, GraphQLNaming.FormatNameForField(input));
+ }
+
+ [TestMethod]
+ public void Pluralize_ReturnsConfiguredPluralName()
+ {
+ Entity entity = GraphQLTestHelpers.GenerateEntityWithSingularPlural("Book", "Books");
+ NameNode result = GraphQLNaming.Pluralize("Book", entity);
+ Assert.AreEqual("Books", result.Value);
+ }
+
+ [TestMethod]
+ public void ObjectTypeToEntityName_NoModelDirective_ReturnsTypeName()
+ {
+ ObjectTypeDefinitionNode node = ParseObjectType("type Book { id: Int }");
+ Assert.AreEqual("Book", GraphQLNaming.ObjectTypeToEntityName(node));
+ }
+
+ [TestMethod]
+ public void ObjectTypeToEntityName_ModelDirectiveWithName_ReturnsModelName()
+ {
+ ObjectTypeDefinitionNode node = ParseObjectType(@"type Book @model(name: ""TopLevelBook"") { id: Int }");
+ Assert.AreEqual("TopLevelBook", GraphQLNaming.ObjectTypeToEntityName(node));
+ }
+
+ [TestMethod]
+ public void ObjectTypeToEntityName_ModelDirectiveNoArguments_ReturnsTypeName()
+ {
+ ObjectTypeDefinitionNode node = ParseObjectType("type Book @model { id: Int }");
+ Assert.AreEqual("Book", GraphQLNaming.ObjectTypeToEntityName(node));
+ }
+
+ [TestMethod]
+ public void GenerateByPKQueryName_UsesSingularWithSuffix()
+ {
+ Entity entity = GraphQLTestHelpers.GenerateEntityWithSingularPlural("Book", "Books");
+ Assert.AreEqual("book_by_pk", GraphQLNaming.GenerateByPKQueryName("Book", entity));
+ }
+
+ [TestMethod]
+ public void GenerateListQueryName_UsesPlural()
+ {
+ Entity entity = GraphQLTestHelpers.GenerateEntityWithSingularPlural("Book", "Books");
+ Assert.AreEqual("books", GraphQLNaming.GenerateListQueryName("Book", entity));
+ }
+
+ [TestMethod]
+ public void GenerateStoredProcedureGraphQLFieldName_PrefixesExecute()
+ {
+ Entity entity = GraphQLTestHelpers.GenerateEntityWithStringType("GetBook");
+ Assert.AreEqual("executeGetBook", GraphQLNaming.GenerateStoredProcedureGraphQLFieldName("GetBook", entity));
+ }
+
+ [TestMethod]
+ public void GenerateLinkingNodeName_ConcatenatesWithPrefix()
+ {
+ Assert.AreEqual("linkingObjectBookAuthor", GraphQLNaming.GenerateLinkingNodeName("Book", "Author"));
+ }
+
+ private static ObjectTypeDefinitionNode ParseObjectType(string sdl)
+ {
+ DocumentNode document = Utf8GraphQLParser.Parse(sdl);
+ return (ObjectTypeDefinitionNode)document.Definitions[0];
+ }
+ }
+}
diff --git a/src/Service.Tests/GraphQLBuilder/GraphQLUtilsTests.cs b/src/Service.Tests/GraphQLBuilder/GraphQLUtilsTests.cs
new file mode 100644
index 0000000000..e0c9a207f6
--- /dev/null
+++ b/src/Service.Tests/GraphQLBuilder/GraphQLUtilsTests.cs
@@ -0,0 +1,158 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Azure.DataApiBuilder.Service.GraphQLBuilder;
+using HotChocolate.Language;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.GraphQLBuilder
+{
+ ///
+ /// Unit tests for the pure static helpers in .
+ ///
+ [TestClass]
+ public class GraphQLUtilsTests
+ {
+ [DataTestMethod]
+ [DataRow(DatabaseType.MSSQL, true)]
+ [DataRow(DatabaseType.MySQL, true)]
+ [DataRow(DatabaseType.DWSQL, true)]
+ [DataRow(DatabaseType.PostgreSQL, true)]
+ [DataRow(DatabaseType.CosmosDB_PostgreSQL, true)]
+ [DataRow(DatabaseType.CosmosDB_NoSQL, false)]
+ public void IsRelationalDb(DatabaseType databaseType, bool expected)
+ {
+ Assert.AreEqual(expected, GraphQLUtils.IsRelationalDb(databaseType));
+ }
+
+ [DataTestMethod]
+ [DataRow("String", true)]
+ [DataRow("Int", true)]
+ [DataRow("Boolean", true)]
+ [DataRow("ID", true)]
+ [DataRow("Book", false)]
+ [DataRow("CustomType", false)]
+ public void IsBuiltInType(string typeName, bool expected)
+ {
+ ITypeNode typeNode = new NamedTypeNode(new NameNode(typeName));
+ Assert.AreEqual(expected, GraphQLUtils.IsBuiltInType(typeNode));
+ }
+
+ [TestMethod]
+ public void IsModelType_WithModelDirective_ReturnsTrue()
+ {
+ ObjectTypeDefinitionNode node = ParseObjectType(@"type Book @model(name: ""Book"") { id: Int }");
+ Assert.IsTrue(GraphQLUtils.IsModelType(node));
+ }
+
+ [TestMethod]
+ public void IsModelType_WithoutModelDirective_ReturnsFalse()
+ {
+ ObjectTypeDefinitionNode node = ParseObjectType("type Book { id: Int }");
+ Assert.IsFalse(GraphQLUtils.IsModelType(node));
+ }
+
+ [TestMethod]
+ public void FindPrimaryKeyFields_CosmosNoSql_ReturnsIdAndPartitionKey()
+ {
+ ObjectTypeDefinitionNode node = ParseObjectType("type Book { title: String }");
+ List pkFields = GraphQLUtils.FindPrimaryKeyFields(node, DatabaseType.CosmosDB_NoSQL);
+
+ Assert.AreEqual(2, pkFields.Count);
+ CollectionAssert.AreEquivalent(
+ new[] { GraphQLUtils.DEFAULT_PRIMARY_KEY_NAME, GraphQLUtils.DEFAULT_PARTITION_KEY_NAME },
+ pkFields.ConvertAll(f => f.Name.Value));
+ }
+
+ [TestMethod]
+ public void FindPrimaryKeyFields_PrimaryKeyDirective_ReturnsDirectiveField()
+ {
+ ObjectTypeDefinitionNode node = ParseObjectType("type Book { book_id: Int @primaryKey title: String }");
+ List pkFields = GraphQLUtils.FindPrimaryKeyFields(node, DatabaseType.MSSQL);
+
+ Assert.AreEqual(1, pkFields.Count);
+ Assert.AreEqual("book_id", pkFields[0].Name.Value);
+ }
+
+ [TestMethod]
+ public void FindPrimaryKeyFields_NoDirective_FallsBackToIdField()
+ {
+ ObjectTypeDefinitionNode node = ParseObjectType("type Book { id: Int title: String }");
+ List pkFields = GraphQLUtils.FindPrimaryKeyFields(node, DatabaseType.MSSQL);
+
+ Assert.AreEqual(1, pkFields.Count);
+ Assert.AreEqual("id", pkFields[0].Name.Value);
+ }
+
+ [TestMethod]
+ public void FindPrimaryKeyFields_NoDirectiveNoIdField_Throws()
+ {
+ ObjectTypeDefinitionNode node = ParseObjectType("type Book { title: String }");
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => GraphQLUtils.FindPrimaryKeyFields(node, DatabaseType.MSSQL));
+
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ErrorInInitialization, ex.SubStatusCode);
+ }
+
+ [TestMethod]
+ public void IsAutoGeneratedField_WithDirective_ReturnsTrue()
+ {
+ ObjectTypeDefinitionNode node = ParseObjectType("type Book { id: Int @autoGenerated }");
+ Assert.IsTrue(GraphQLUtils.IsAutoGeneratedField(node.Fields[0]));
+ }
+
+ [TestMethod]
+ public void IsAutoGeneratedField_WithoutDirective_ReturnsFalse()
+ {
+ ObjectTypeDefinitionNode node = ParseObjectType("type Book { id: Int }");
+ Assert.IsFalse(GraphQLUtils.IsAutoGeneratedField(node.Fields[0]));
+ }
+
+ [TestMethod]
+ public void CreateAuthorizationDirective_NullRoles_ReturnsFalse()
+ {
+ bool created = GraphQLUtils.CreateAuthorizationDirectiveIfNecessary(null, out DirectiveNode? directive);
+ Assert.IsFalse(created);
+ Assert.IsNull(directive);
+ }
+
+ [TestMethod]
+ public void CreateAuthorizationDirective_EmptyRoles_ReturnsFalse()
+ {
+ bool created = GraphQLUtils.CreateAuthorizationDirectiveIfNecessary(new List(), out DirectiveNode? directive);
+ Assert.IsFalse(created);
+ Assert.IsNull(directive);
+ }
+
+ [TestMethod]
+ public void CreateAuthorizationDirective_ContainsAnonymous_ReturnsFalse()
+ {
+ bool created = GraphQLUtils.CreateAuthorizationDirectiveIfNecessary(
+ new[] { "role1", GraphQLUtils.SYSTEM_ROLE_ANONYMOUS }, out DirectiveNode? directive);
+ Assert.IsFalse(created);
+ Assert.IsNull(directive);
+ }
+
+ [TestMethod]
+ public void CreateAuthorizationDirective_WithRoles_ReturnsAuthorizeDirective()
+ {
+ bool created = GraphQLUtils.CreateAuthorizationDirectiveIfNecessary(
+ new[] { "role1", "role2" }, out DirectiveNode? directive);
+
+ Assert.IsTrue(created);
+ Assert.IsNotNull(directive);
+ Assert.AreEqual(GraphQLUtils.AUTHORIZE_DIRECTIVE, directive!.Name.Value);
+ Assert.AreEqual(1, directive.Arguments.Count);
+ Assert.AreEqual(GraphQLUtils.AUTHORIZE_DIRECTIVE_ARGUMENT_ROLES, directive.Arguments[0].Name.Value);
+ }
+
+ private static ObjectTypeDefinitionNode ParseObjectType(string sdl)
+ {
+ DocumentNode document = Utf8GraphQLParser.Parse(sdl);
+ return (ObjectTypeDefinitionNode)document.Definitions[0];
+ }
+ }
+}
diff --git a/src/Service.Tests/GraphQLBuilder/Sql/GraphQLStoredProcedureBuilderHelpersTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/GraphQLStoredProcedureBuilderHelpersTests.cs
new file mode 100644
index 0000000000..f6c048f9a9
--- /dev/null
+++ b/src/Service.Tests/GraphQLBuilder/Sql/GraphQLStoredProcedureBuilderHelpersTests.cs
@@ -0,0 +1,59 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using System.Text.Json;
+using Azure.DataApiBuilder.Service.GraphQLBuilder;
+using HotChocolate.Language;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.GraphQLBuilder.Sql
+{
+ ///
+ /// Unit tests for the pure helper methods on that
+ /// shape stored-procedure results and default result fields.
+ ///
+ [TestClass]
+ public class GraphQLStoredProcedureBuilderHelpersTests
+ {
+ [TestMethod]
+ public void FormatStoredProcedureResultAsJsonList_Null_ReturnsEmptyList()
+ {
+ List result = GraphQLStoredProcedureBuilder.FormatStoredProcedureResultAsJsonList(null);
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(0, result.Count);
+ }
+
+ [TestMethod]
+ public void FormatStoredProcedureResultAsJsonList_EmptyArray_ReturnsEmptyList()
+ {
+ using JsonDocument input = JsonDocument.Parse("[]");
+ List result = GraphQLStoredProcedureBuilder.FormatStoredProcedureResultAsJsonList(input);
+
+ Assert.AreEqual(0, result.Count);
+ }
+
+ [TestMethod]
+ public void FormatStoredProcedureResultAsJsonList_MultipleRows_ReturnsOneDocumentPerRow()
+ {
+ using JsonDocument input = JsonDocument.Parse(@"[{""id"":1,""title"":""A""},{""id"":2,""title"":""B""}]");
+ List result = GraphQLStoredProcedureBuilder.FormatStoredProcedureResultAsJsonList(input);
+
+ Assert.AreEqual(2, result.Count);
+ Assert.AreEqual(1, result[0].RootElement.GetProperty("id").GetInt32());
+ Assert.AreEqual("B", result[1].RootElement.GetProperty("title").GetString());
+ }
+
+ [TestMethod]
+ public void GetDefaultResultFieldForStoredProcedure_ReturnsResultStringField()
+ {
+ FieldDefinitionNode field = GraphQLStoredProcedureBuilder.GetDefaultResultFieldForStoredProcedure();
+
+ Assert.AreEqual("result", field.Name.Value);
+ Assert.AreEqual(0, field.Arguments.Count);
+ Assert.IsInstanceOfType(field.Type, typeof(NamedTypeNode));
+ Assert.AreEqual("String", ((NamedTypeNode)field.Type).Name.Value);
+ }
+ }
+}
diff --git a/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTypeMappingTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTypeMappingTests.cs
new file mode 100644
index 0000000000..ca6f030c10
--- /dev/null
+++ b/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTypeMappingTests.cs
@@ -0,0 +1,92 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Azure.DataApiBuilder.Service.GraphQLBuilder.Sql;
+using HotChocolate.Language;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedHotChocolateTypes;
+
+namespace Azure.DataApiBuilder.Service.Tests.GraphQLBuilder.Sql
+{
+ ///
+ /// Unit tests for covering
+ /// scalar, array, and unsupported-type branches.
+ ///
+ [TestClass]
+ public class SchemaConverterTypeMappingTests
+ {
+ [DataTestMethod]
+ [DataRow(typeof(string), STRING_TYPE)]
+ [DataRow(typeof(Guid), UUID_TYPE)]
+ [DataRow(typeof(byte), BYTE_TYPE)]
+ [DataRow(typeof(short), SHORT_TYPE)]
+ [DataRow(typeof(int), INT_TYPE)]
+ [DataRow(typeof(long), LONG_TYPE)]
+ [DataRow(typeof(float), SINGLE_TYPE)]
+ [DataRow(typeof(double), FLOAT_TYPE)]
+ [DataRow(typeof(decimal), DECIMAL_TYPE)]
+ [DataRow(typeof(bool), BOOLEAN_TYPE)]
+ [DataRow(typeof(DateTime), DATETIME_TYPE)]
+ [DataRow(typeof(DateTimeOffset), DATETIME_TYPE)]
+ [DataRow(typeof(byte[]), BYTEARRAY_TYPE)]
+ [DataRow(typeof(TimeOnly), LOCALTIME_TYPE)]
+ [DataRow(typeof(TimeSpan), LOCALTIME_TYPE)]
+ public void GetGraphQLTypeFromSystemType_ScalarTypes(Type systemType, string expected)
+ {
+ Assert.AreEqual(expected, SchemaConverter.GetGraphQLTypeFromSystemType(systemType));
+ }
+
+ [TestMethod]
+ public void GetGraphQLTypeFromSystemType_ArrayType_ResolvesElementType()
+ {
+ // int[] resolves to its element type's GraphQL type.
+ Assert.AreEqual(INT_TYPE, SchemaConverter.GetGraphQLTypeFromSystemType(typeof(int[])));
+ Assert.AreEqual(STRING_TYPE, SchemaConverter.GetGraphQLTypeFromSystemType(typeof(string[])));
+ }
+
+ [TestMethod]
+ public void GetGraphQLTypeFromSystemType_AbstractArray_DefaultsToString()
+ {
+ // Npgsql may report the abstract System.Array for unresolved array columns.
+ Assert.AreEqual(STRING_TYPE, SchemaConverter.GetGraphQLTypeFromSystemType(typeof(Array)));
+ }
+
+ [TestMethod]
+ public void GetGraphQLTypeFromSystemType_UnsupportedType_Throws()
+ {
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => SchemaConverter.GetGraphQLTypeFromSystemType(typeof(object)));
+
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.GraphQLMapping, ex.SubStatusCode);
+ }
+
+ [DataTestMethod]
+ [DataRow((byte)5, BYTE_TYPE)]
+ [DataRow((short)5, SHORT_TYPE)]
+ [DataRow(5, INT_TYPE)]
+ [DataRow(5L, LONG_TYPE)]
+ [DataRow("text", STRING_TYPE)]
+ [DataRow(true, BOOLEAN_TYPE)]
+ [DataRow(1.5f, SINGLE_TYPE)]
+ [DataRow(1.5d, FLOAT_TYPE)]
+ public void CreateValueNodeFromDbObjectMetadata_ScalarTypes_WrapsInObjectFieldNode(object value, string expectedTypeName)
+ {
+ IValueNode node = SchemaConverter.CreateValueNodeFromDbObjectMetadata(value);
+
+ ObjectValueNode objectValueNode = (ObjectValueNode)node;
+ Assert.AreEqual(1, objectValueNode.Fields.Count);
+ Assert.AreEqual(expectedTypeName, objectValueNode.Fields[0].Name.Value);
+ }
+
+ [TestMethod]
+ public void CreateValueNodeFromDbObjectMetadata_UnsupportedType_Throws()
+ {
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => SchemaConverter.CreateValueNodeFromDbObjectMetadata(new object()));
+
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.GraphQLMapping, ex.SubStatusCode);
+ }
+ }
+}
diff --git a/src/Service.Tests/Mcp/BuiltInDmlToolValidationTests.cs b/src/Service.Tests/Mcp/BuiltInDmlToolValidationTests.cs
new file mode 100644
index 0000000000..884abb2133
--- /dev/null
+++ b/src/Service.Tests/Mcp/BuiltInDmlToolValidationTests.cs
@@ -0,0 +1,314 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.DataApiBuilder.Auth;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Authorization;
+using Azure.DataApiBuilder.Core.Configurations;
+using Azure.DataApiBuilder.Mcp.BuiltInTools;
+using Azure.DataApiBuilder.Mcp.Model;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ModelContextProtocol.Protocol;
+using Moq;
+
+namespace Azure.DataApiBuilder.Service.Tests.Mcp
+{
+ ///
+ /// Non-database unit tests for the built-in DML MCP tools (create/read/update/delete_record).
+ /// Covers the early-return branches that execute before any database access:
+ /// tool-disabled (runtime and entity level), null/invalid arguments, and metadata-resolution
+ /// failures. No test server or database is required.
+ ///
+ [TestClass]
+ public class BuiltInDmlToolValidationTests
+ {
+ #region Tool metadata & IsEnabled
+
+ [TestMethod]
+ public void ToolMetadata_HasExpectedNames()
+ {
+ Assert.AreEqual("create_record", new CreateRecordTool().GetToolMetadata().Name);
+ Assert.AreEqual("read_records", new ReadRecordsTool().GetToolMetadata().Name);
+ Assert.AreEqual("update_record", new UpdateRecordTool().GetToolMetadata().Name);
+ Assert.AreEqual("delete_record", new DeleteRecordTool().GetToolMetadata().Name);
+ }
+
+ [DataTestMethod]
+ [DataRow(true)]
+ [DataRow(false)]
+ public void IsEnabled_ReflectsDmlToolsConfig(bool enabled)
+ {
+ RuntimeConfig config = CreateConfig(
+ createEnabled: enabled, readEnabled: enabled, updateEnabled: enabled, deleteEnabled: enabled);
+
+ Assert.AreEqual(enabled, new CreateRecordTool().IsEnabled(config));
+ Assert.AreEqual(enabled, new ReadRecordsTool().IsEnabled(config));
+ Assert.AreEqual(enabled, new UpdateRecordTool().IsEnabled(config));
+ Assert.AreEqual(enabled, new DeleteRecordTool().IsEnabled(config));
+ }
+
+ #endregion
+
+ #region Null arguments
+
+ [TestMethod]
+ public async Task CreateRecord_NullArguments_ReturnsInvalidArguments()
+ {
+ CallToolResult result = await ExecuteAsync(new CreateRecordTool(), CreateServiceProvider(CreateConfig()), arguments: null);
+ AssertErrorType(result, "InvalidArguments");
+ }
+
+ [TestMethod]
+ public async Task ReadRecords_NullArguments_ReturnsInvalidArguments()
+ {
+ CallToolResult result = await ExecuteAsync(new ReadRecordsTool(), CreateServiceProvider(CreateConfig()), arguments: null);
+ AssertErrorType(result, "InvalidArguments");
+ }
+
+ [TestMethod]
+ public async Task UpdateRecord_NullArguments_ReturnsInvalidArguments()
+ {
+ CallToolResult result = await ExecuteAsync(new UpdateRecordTool(), CreateServiceProvider(CreateConfig()), arguments: null);
+ AssertErrorType(result, "InvalidArguments");
+ }
+
+ [TestMethod]
+ public async Task DeleteRecord_NullArguments_ReturnsInvalidArguments()
+ {
+ CallToolResult result = await ExecuteAsync(new DeleteRecordTool(), CreateServiceProvider(CreateConfig()), arguments: null);
+ AssertErrorType(result, "InvalidArguments");
+ }
+
+ #endregion
+
+ #region Runtime-level tool disabled
+
+ [TestMethod]
+ public async Task CreateRecord_RuntimeDisabled_ReturnsToolDisabled()
+ {
+ IServiceProvider sp = CreateServiceProvider(CreateConfig(createEnabled: false));
+ CallToolResult result = await ExecuteAsync(new CreateRecordTool(), sp, "{\"entity\":\"Book\",\"data\":{\"title\":\"x\"}}");
+ AssertErrorType(result, "ToolDisabled");
+ }
+
+ [TestMethod]
+ public async Task ReadRecords_RuntimeDisabled_ReturnsToolDisabled()
+ {
+ IServiceProvider sp = CreateServiceProvider(CreateConfig(readEnabled: false));
+ CallToolResult result = await ExecuteAsync(new ReadRecordsTool(), sp, "{\"entity\":\"Book\"}");
+ AssertErrorType(result, "ToolDisabled");
+ }
+
+ [TestMethod]
+ public async Task UpdateRecord_RuntimeDisabled_ReturnsToolDisabled()
+ {
+ IServiceProvider sp = CreateServiceProvider(CreateConfig(updateEnabled: false));
+ CallToolResult result = await ExecuteAsync(new UpdateRecordTool(), sp, "{\"entity\":\"Book\",\"keys\":{\"id\":1},\"fields\":{\"title\":\"x\"}}");
+ AssertErrorType(result, "ToolDisabled");
+ }
+
+ [TestMethod]
+ public async Task DeleteRecord_RuntimeDisabled_ReturnsToolDisabled()
+ {
+ IServiceProvider sp = CreateServiceProvider(CreateConfig(deleteEnabled: false));
+ CallToolResult result = await ExecuteAsync(new DeleteRecordTool(), sp, "{\"entity\":\"Book\",\"keys\":{\"id\":1}}");
+ AssertErrorType(result, "ToolDisabled");
+ }
+
+ #endregion
+
+ #region Invalid arguments
+
+ [TestMethod]
+ public async Task CreateRecord_MissingData_ReturnsInvalidArguments()
+ {
+ IServiceProvider sp = CreateServiceProvider(CreateConfig());
+ CallToolResult result = await ExecuteAsync(new CreateRecordTool(), sp, "{\"entity\":\"Book\"}");
+ AssertErrorType(result, "InvalidArguments");
+ }
+
+ [TestMethod]
+ public async Task ReadRecords_MissingEntity_ReturnsInvalidArguments()
+ {
+ IServiceProvider sp = CreateServiceProvider(CreateConfig());
+ CallToolResult result = await ExecuteAsync(new ReadRecordsTool(), sp, "{\"select\":\"id\"}");
+ AssertErrorType(result, "InvalidArguments");
+ }
+
+ [TestMethod]
+ public async Task UpdateRecord_MissingFields_ReturnsInvalidArguments()
+ {
+ IServiceProvider sp = CreateServiceProvider(CreateConfig());
+ CallToolResult result = await ExecuteAsync(new UpdateRecordTool(), sp, "{\"entity\":\"Book\",\"keys\":{\"id\":1}}");
+ AssertErrorType(result, "InvalidArguments");
+ }
+
+ [TestMethod]
+ public async Task DeleteRecord_MissingKeys_ReturnsInvalidArguments()
+ {
+ IServiceProvider sp = CreateServiceProvider(CreateConfig());
+ CallToolResult result = await ExecuteAsync(new DeleteRecordTool(), sp, "{\"entity\":\"Book\"}");
+ AssertErrorType(result, "InvalidArguments");
+ }
+
+ #endregion
+
+ #region Entity-level DML disabled
+
+ [TestMethod]
+ public async Task CreateRecord_EntityDmlDisabled_ReturnsToolDisabled()
+ {
+ IServiceProvider sp = CreateServiceProvider(CreateConfig(entityDmlEnabled: false));
+ CallToolResult result = await ExecuteAsync(new CreateRecordTool(), sp, "{\"entity\":\"Book\",\"data\":{\"title\":\"x\"}}");
+ AssertErrorType(result, "ToolDisabled");
+ }
+
+ [TestMethod]
+ public async Task DeleteRecord_EntityDmlDisabled_ReturnsToolDisabled()
+ {
+ IServiceProvider sp = CreateServiceProvider(CreateConfig(entityDmlEnabled: false));
+ CallToolResult result = await ExecuteAsync(new DeleteRecordTool(), sp, "{\"entity\":\"Book\",\"keys\":{\"id\":1}}");
+ AssertErrorType(result, "ToolDisabled");
+ }
+
+ [TestMethod]
+ public async Task UpdateRecord_EntityDmlDisabled_ReturnsToolDisabled()
+ {
+ IServiceProvider sp = CreateServiceProvider(CreateConfig(entityDmlEnabled: false));
+ CallToolResult result = await ExecuteAsync(new UpdateRecordTool(), sp, "{\"entity\":\"Book\",\"keys\":{\"id\":1},\"fields\":{\"title\":\"x\"}}");
+ AssertErrorType(result, "ToolDisabled");
+ }
+
+ #endregion
+
+ #region Metadata resolution failure (no metadata provider registered)
+
+ [TestMethod]
+ public async Task CreateRecord_UnresolvableMetadata_ReturnsError()
+ {
+ IServiceProvider sp = CreateServiceProvider(CreateConfig());
+ CallToolResult result = await ExecuteAsync(new CreateRecordTool(), sp, "{\"entity\":\"Book\",\"data\":{\"title\":\"x\"}}");
+ Assert.IsTrue(result.IsError == true);
+ }
+
+ [TestMethod]
+ public async Task ReadRecords_UnresolvableMetadata_ReturnsError()
+ {
+ IServiceProvider sp = CreateServiceProvider(CreateConfig());
+ CallToolResult result = await ExecuteAsync(new ReadRecordsTool(), sp, "{\"entity\":\"Book\"}");
+ Assert.IsTrue(result.IsError == true);
+ }
+
+ [TestMethod]
+ public async Task DeleteRecord_UnresolvableMetadata_ReturnsError()
+ {
+ IServiceProvider sp = CreateServiceProvider(CreateConfig());
+ CallToolResult result = await ExecuteAsync(new DeleteRecordTool(), sp, "{\"entity\":\"Book\",\"keys\":{\"id\":1}}");
+ Assert.IsTrue(result.IsError == true);
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private static async Task ExecuteAsync(IMcpTool tool, IServiceProvider sp, string? arguments)
+ {
+ if (arguments is null)
+ {
+ return await tool.ExecuteAsync(null, sp, CancellationToken.None);
+ }
+
+ using JsonDocument args = JsonDocument.Parse(arguments);
+ return await tool.ExecuteAsync(args, sp, CancellationToken.None);
+ }
+
+ private static void AssertErrorType(CallToolResult result, string expectedType)
+ {
+ Assert.IsTrue(result.IsError == true, "Expected an error result.");
+ TextContentBlock block = (TextContentBlock)result.Content[0];
+ JsonElement root = JsonDocument.Parse(block.Text).RootElement;
+ Assert.AreEqual(expectedType, root.GetProperty("error").GetProperty("type").GetString());
+ }
+
+ private static RuntimeConfig CreateConfig(
+ bool createEnabled = true,
+ bool readEnabled = true,
+ bool updateEnabled = true,
+ bool deleteEnabled = true,
+ bool entityDmlEnabled = true)
+ {
+ Dictionary entities = new()
+ {
+ ["Book"] = new Entity(
+ Source: new("books", EntitySourceType.Table, null, null),
+ GraphQL: new("Book", "Books"),
+ Fields: null,
+ Rest: new(Enabled: true),
+ Permissions: new[]
+ {
+ new EntityPermission(Role: "anonymous", Actions: new[]
+ {
+ new EntityAction(Action: EntityActionOperation.All, Fields: null, Policy: null)
+ })
+ },
+ Mappings: null,
+ Relationships: null,
+ Mcp: entityDmlEnabled ? null : new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: false))
+ };
+
+ return new RuntimeConfig(
+ Schema: "test-schema",
+ DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null),
+ Runtime: new(
+ Rest: new(),
+ GraphQL: new(),
+ Mcp: new(
+ Enabled: true,
+ Path: "/mcp",
+ DmlTools: new(
+ describeEntities: true,
+ readRecords: readEnabled,
+ createRecord: createEnabled,
+ updateRecord: updateEnabled,
+ deleteRecord: deleteEnabled,
+ executeEntity: true,
+ aggregateRecords: true)),
+ Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)),
+ Entities: new(entities));
+ }
+
+ private static IServiceProvider CreateServiceProvider(RuntimeConfig config)
+ {
+ ServiceCollection services = new();
+
+ RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config);
+ services.AddSingleton(configProvider);
+
+ Mock authResolver = new();
+ authResolver.Setup(x => x.IsValidRoleContext(It.IsAny())).Returns(true);
+ services.AddSingleton(authResolver.Object);
+
+ Mock httpContext = new();
+ Mock request = new();
+ request.Setup(x => x.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns("anonymous");
+ httpContext.Setup(x => x.Request).Returns(request.Object);
+
+ Mock httpContextAccessor = new();
+ httpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext.Object);
+ services.AddSingleton(httpContextAccessor.Object);
+
+ services.AddLogging();
+
+ return services.BuildServiceProvider();
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Service.Tests/Mcp/McpArgumentParserTests.cs b/src/Service.Tests/Mcp/McpArgumentParserTests.cs
new file mode 100644
index 0000000000..6054cd7983
--- /dev/null
+++ b/src/Service.Tests/Mcp/McpArgumentParserTests.cs
@@ -0,0 +1,221 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using System.Text.Json;
+using Azure.DataApiBuilder.Mcp.Utils;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.Mcp
+{
+ ///
+ /// Unit tests for covering argument parsing and
+ /// validation for the built-in MCP DML tools. Pure logic; no database required.
+ ///
+ [TestClass]
+ public class McpArgumentParserTests
+ {
+ private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement;
+
+ [TestMethod]
+ public void TryParseEntity_ValidEntity_ReturnsTrue()
+ {
+ bool ok = McpArgumentParser.TryParseEntity(Parse(@"{ ""entity"": ""Book"" }"), out string entity, out string error);
+
+ Assert.IsTrue(ok);
+ Assert.AreEqual("Book", entity);
+ Assert.AreEqual(string.Empty, error);
+ }
+
+ [TestMethod]
+ public void TryParseEntity_MissingEntity_ReturnsFalse()
+ {
+ bool ok = McpArgumentParser.TryParseEntity(Parse(@"{ ""other"": ""x"" }"), out _, out string error);
+
+ Assert.IsFalse(ok);
+ StringAssert.Contains(error, "entity");
+ }
+
+ [DataTestMethod]
+ [DataRow(@"{ ""entity"": """" }", DisplayName = "Empty entity")]
+ [DataRow(@"{ ""entity"": "" "" }", DisplayName = "Whitespace entity")]
+ public void TryParseEntity_EmptyEntity_ReturnsFalse(string json)
+ {
+ bool ok = McpArgumentParser.TryParseEntity(Parse(json), out _, out string error);
+
+ Assert.IsFalse(ok);
+ StringAssert.Contains(error, "Entity is required");
+ }
+
+ [TestMethod]
+ public void TryParseEntityAndData_Valid_ReturnsTrue()
+ {
+ bool ok = McpArgumentParser.TryParseEntityAndData(
+ Parse(@"{ ""entity"": ""Book"", ""data"": { ""title"": ""t"" } }"),
+ out string entity, out JsonElement data, out string error);
+
+ Assert.IsTrue(ok);
+ Assert.AreEqual("Book", entity);
+ Assert.AreEqual(JsonValueKind.Object, data.ValueKind);
+ Assert.AreEqual(string.Empty, error);
+ }
+
+ [TestMethod]
+ public void TryParseEntityAndData_MissingData_ReturnsFalse()
+ {
+ bool ok = McpArgumentParser.TryParseEntityAndData(
+ Parse(@"{ ""entity"": ""Book"" }"), out _, out _, out string error);
+
+ Assert.IsFalse(ok);
+ StringAssert.Contains(error, "data");
+ }
+
+ [TestMethod]
+ public void TryParseEntityAndData_DataNotObject_ReturnsFalse()
+ {
+ bool ok = McpArgumentParser.TryParseEntityAndData(
+ Parse(@"{ ""entity"": ""Book"", ""data"": 5 }"), out _, out _, out string error);
+
+ Assert.IsFalse(ok);
+ StringAssert.Contains(error, "JSON object");
+ }
+
+ [TestMethod]
+ public void TryParseEntityAndKeys_Valid_ReturnsTrue()
+ {
+ bool ok = McpArgumentParser.TryParseEntityAndKeys(
+ Parse(@"{ ""entity"": ""Book"", ""keys"": { ""id"": 1 } }"),
+ out string entity, out Dictionary keys, out string error);
+
+ Assert.IsTrue(ok);
+ Assert.AreEqual("Book", entity);
+ Assert.AreEqual(1, keys.Count);
+ Assert.AreEqual(string.Empty, error);
+ }
+
+ [TestMethod]
+ public void TryParseEntityAndKeys_MissingKeys_ReturnsFalse()
+ {
+ bool ok = McpArgumentParser.TryParseEntityAndKeys(
+ Parse(@"{ ""entity"": ""Book"" }"), out _, out _, out string error);
+
+ Assert.IsFalse(ok);
+ StringAssert.Contains(error, "keys");
+ }
+
+ [TestMethod]
+ public void TryParseEntityAndKeys_KeysNotObject_ReturnsFalse()
+ {
+ bool ok = McpArgumentParser.TryParseEntityAndKeys(
+ Parse(@"{ ""entity"": ""Book"", ""keys"": [1, 2] }"), out _, out _, out string error);
+
+ Assert.IsFalse(ok);
+ StringAssert.Contains(error, "JSON object");
+ }
+
+ [TestMethod]
+ public void TryParseEntityAndKeys_EmptyKeys_ReturnsFalse()
+ {
+ bool ok = McpArgumentParser.TryParseEntityAndKeys(
+ Parse(@"{ ""entity"": ""Book"", ""keys"": { } }"), out _, out _, out string error);
+
+ Assert.IsFalse(ok);
+ StringAssert.Contains(error, "Keys are required");
+ }
+
+ [TestMethod]
+ public void TryParseEntityAndKeys_NullKeyValue_ReturnsFalse()
+ {
+ bool ok = McpArgumentParser.TryParseEntityAndKeys(
+ Parse(@"{ ""entity"": ""Book"", ""keys"": { ""id"": null } }"), out _, out _, out string error);
+
+ Assert.IsFalse(ok);
+ StringAssert.Contains(error, "cannot be null or empty");
+ }
+
+ [TestMethod]
+ public void TryParseEntityKeysAndFields_Valid_ReturnsTrue()
+ {
+ bool ok = McpArgumentParser.TryParseEntityKeysAndFields(
+ Parse(@"{ ""entity"": ""Book"", ""keys"": { ""id"": 1 }, ""fields"": { ""title"": ""t"" } }"),
+ out string entity, out Dictionary keys, out Dictionary fields, out string error);
+
+ Assert.IsTrue(ok);
+ Assert.AreEqual("Book", entity);
+ Assert.AreEqual(1, keys.Count);
+ Assert.AreEqual(1, fields.Count);
+ Assert.AreEqual(string.Empty, error);
+ }
+
+ [TestMethod]
+ public void TryParseEntityKeysAndFields_MissingFields_ReturnsFalse()
+ {
+ bool ok = McpArgumentParser.TryParseEntityKeysAndFields(
+ Parse(@"{ ""entity"": ""Book"", ""keys"": { ""id"": 1 } }"),
+ out _, out _, out _, out string error);
+
+ Assert.IsFalse(ok);
+ StringAssert.Contains(error, "fields");
+ }
+
+ [TestMethod]
+ public void TryParseEntityKeysAndFields_EmptyFields_ReturnsFalse()
+ {
+ bool ok = McpArgumentParser.TryParseEntityKeysAndFields(
+ Parse(@"{ ""entity"": ""Book"", ""keys"": { ""id"": 1 }, ""fields"": { } }"),
+ out _, out _, out _, out string error);
+
+ Assert.IsFalse(ok);
+ StringAssert.Contains(error, "field must be provided");
+ }
+
+ [TestMethod]
+ public void TryParseExecuteArguments_NonObjectRoot_ReturnsFalse()
+ {
+ bool ok = McpArgumentParser.TryParseExecuteArguments(
+ Parse("[1, 2, 3]"), out _, out _, out string error);
+
+ Assert.IsFalse(ok);
+ StringAssert.Contains(error, "Arguments must be an object");
+ }
+
+ [TestMethod]
+ public void TryParseExecuteArguments_WithTypedParameters_AreConverted()
+ {
+ string json = @"{
+ ""entity"": ""GetBooks"",
+ ""parameters"": {
+ ""name"": ""abc"",
+ ""count"": 7,
+ ""ratio"": 1.5,
+ ""active"": true,
+ ""nothing"": null
+ }
+ }";
+
+ bool ok = McpArgumentParser.TryParseExecuteArguments(
+ Parse(json), out string entity, out Dictionary parameters, out string error);
+
+ Assert.IsTrue(ok);
+ Assert.AreEqual("GetBooks", entity);
+ Assert.AreEqual(string.Empty, error);
+ Assert.AreEqual("abc", parameters["name"]);
+ Assert.AreEqual(7L, parameters["count"]);
+ Assert.AreEqual(1.5m, parameters["ratio"]);
+ Assert.AreEqual(true, parameters["active"]);
+ Assert.IsNull(parameters["nothing"]);
+ }
+
+ [TestMethod]
+ public void TryParseExecuteArguments_NoParameters_ReturnsEmptyDictionary()
+ {
+ bool ok = McpArgumentParser.TryParseExecuteArguments(
+ Parse(@"{ ""entity"": ""GetBooks"" }"),
+ out string entity, out Dictionary parameters, out _);
+
+ Assert.IsTrue(ok);
+ Assert.AreEqual("GetBooks", entity);
+ Assert.AreEqual(0, parameters.Count);
+ }
+ }
+}
diff --git a/src/Service.Tests/Mcp/McpAuthorizationHelperTests.cs b/src/Service.Tests/Mcp/McpAuthorizationHelperTests.cs
new file mode 100644
index 0000000000..c5d7ef0443
--- /dev/null
+++ b/src/Service.Tests/Mcp/McpAuthorizationHelperTests.cs
@@ -0,0 +1,120 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.DataApiBuilder.Auth;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Authorization;
+using Azure.DataApiBuilder.Mcp.Utils;
+using Microsoft.AspNetCore.Http;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Azure.DataApiBuilder.Service.Tests.Mcp
+{
+ ///
+ /// Unit tests for . Uses a mocked
+ /// ; no database required.
+ ///
+ [TestClass]
+ public class McpAuthorizationHelperTests
+ {
+ [TestMethod]
+ public void ValidateRoleContext_NullHttpContext_ReturnsFalse()
+ {
+ Mock resolver = new();
+
+ bool ok = McpAuthorizationHelper.ValidateRoleContext(null, resolver.Object, out string error);
+
+ Assert.IsFalse(ok);
+ StringAssert.Contains(error, "valid role context");
+ }
+
+ [TestMethod]
+ public void ValidateRoleContext_InvalidRoleContext_ReturnsFalse()
+ {
+ Mock resolver = new();
+ resolver.Setup(r => r.IsValidRoleContext(It.IsAny())).Returns(false);
+
+ bool ok = McpAuthorizationHelper.ValidateRoleContext(new DefaultHttpContext(), resolver.Object, out string error);
+
+ Assert.IsFalse(ok);
+ StringAssert.Contains(error, "valid role context");
+ }
+
+ [TestMethod]
+ public void ValidateRoleContext_ValidRoleContext_ReturnsTrue()
+ {
+ Mock resolver = new();
+ resolver.Setup(r => r.IsValidRoleContext(It.IsAny())).Returns(true);
+
+ bool ok = McpAuthorizationHelper.ValidateRoleContext(new DefaultHttpContext(), resolver.Object, out string error);
+
+ Assert.IsTrue(ok);
+ Assert.AreEqual(string.Empty, error);
+ }
+
+ [TestMethod]
+ public void TryResolveAuthorizedRole_MissingRoleHeader_ReturnsFalse()
+ {
+ Mock resolver = new();
+
+ bool ok = McpAuthorizationHelper.TryResolveAuthorizedRole(
+ new DefaultHttpContext(),
+ resolver.Object,
+ "Book",
+ EntityActionOperation.Read,
+ out string? effectiveRole,
+ out string error);
+
+ Assert.IsFalse(ok);
+ Assert.IsNull(effectiveRole);
+ StringAssert.Contains(error, "role header");
+ }
+
+ [TestMethod]
+ public void TryResolveAuthorizedRole_AllowedRole_ReturnsTrue()
+ {
+ Mock resolver = new();
+ resolver.Setup(r => r.AreRoleAndOperationDefinedForEntity("Book", "reader", EntityActionOperation.Read))
+ .Returns(true);
+
+ DefaultHttpContext httpContext = new();
+ httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = "reader";
+
+ bool ok = McpAuthorizationHelper.TryResolveAuthorizedRole(
+ httpContext,
+ resolver.Object,
+ "Book",
+ EntityActionOperation.Read,
+ out string? effectiveRole,
+ out string error);
+
+ Assert.IsTrue(ok);
+ Assert.AreEqual("reader", effectiveRole);
+ Assert.AreEqual(string.Empty, error);
+ }
+
+ [TestMethod]
+ public void TryResolveAuthorizedRole_NoAllowedRole_ReturnsFalse()
+ {
+ Mock resolver = new();
+ resolver.Setup(r => r.AreRoleAndOperationDefinedForEntity(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(false);
+
+ DefaultHttpContext httpContext = new();
+ httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = "reader,writer";
+
+ bool ok = McpAuthorizationHelper.TryResolveAuthorizedRole(
+ httpContext,
+ resolver.Object,
+ "Book",
+ EntityActionOperation.Read,
+ out string? effectiveRole,
+ out string error);
+
+ Assert.IsFalse(ok);
+ Assert.IsNull(effectiveRole);
+ StringAssert.Contains(error, "permission");
+ }
+ }
+}
diff --git a/src/Service.Tests/Mcp/McpErrorHelpersTests.cs b/src/Service.Tests/Mcp/McpErrorHelpersTests.cs
new file mode 100644
index 0000000000..8f730204f1
--- /dev/null
+++ b/src/Service.Tests/Mcp/McpErrorHelpersTests.cs
@@ -0,0 +1,78 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json;
+using Azure.DataApiBuilder.Mcp.Model;
+using Azure.DataApiBuilder.Mcp.Utils;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ModelContextProtocol.Protocol;
+
+namespace Azure.DataApiBuilder.Service.Tests.Mcp
+{
+ ///
+ /// Unit tests for covering the standardized error
+ /// responses used across the MCP tools. Pure logic; no database required.
+ ///
+ [TestClass]
+ public class McpErrorHelpersTests
+ {
+ private static (string type, string message) ParseError(CallToolResult result)
+ {
+ TextContentBlock block = (TextContentBlock)result.Content[0];
+ JsonElement error = JsonDocument.Parse(block.Text).RootElement.GetProperty("error");
+ return (error.GetProperty("type").GetString()!, error.GetProperty("message").GetString()!);
+ }
+
+ [TestMethod]
+ public void PermissionDenied_ProducesPermissionDeniedError()
+ {
+ CallToolResult result = McpErrorHelpers.PermissionDenied(
+ "read_records", "Book", "read", "No active HTTP request context.", null);
+
+ Assert.IsTrue(result.IsError == true);
+ (string type, string message) = ParseError(result);
+ Assert.AreEqual(McpErrorCode.PermissionDenied.ToString(), type);
+ StringAssert.Contains(message, "read");
+ StringAssert.Contains(message, "Book");
+ StringAssert.Contains(message, "No active HTTP request context.");
+ }
+
+ [TestMethod]
+ public void ToolDisabled_DefaultMessage_MentionsToolName()
+ {
+ CallToolResult result = McpErrorHelpers.ToolDisabled("create_record", null);
+
+ Assert.IsTrue(result.IsError == true);
+ (string type, string message) = ParseError(result);
+ Assert.AreEqual(McpErrorCode.ToolDisabled.ToString(), type);
+ StringAssert.Contains(message, "create_record");
+ StringAssert.Contains(message, "disabled");
+ }
+
+ [TestMethod]
+ public void ToolDisabled_CustomMessage_IsUsed()
+ {
+ CallToolResult result = McpErrorHelpers.ToolDisabled(
+ "create_record", null, "DML tools are disabled for entity 'Book'.");
+
+ (string type, string message) = ParseError(result);
+ Assert.AreEqual(McpErrorCode.ToolDisabled.ToString(), type);
+ Assert.AreEqual("DML tools are disabled for entity 'Book'.", message);
+ }
+
+ [TestMethod]
+ public void FieldNotFound_IncludesGuidanceToDescribeEntities()
+ {
+ CallToolResult result = McpErrorHelpers.FieldNotFound(
+ "aggregate_records", "Book", "badField", "field", null);
+
+ Assert.IsTrue(result.IsError == true);
+ (string type, string message) = ParseError(result);
+ Assert.AreEqual("FieldNotFound", type);
+ StringAssert.Contains(message, "badField");
+ StringAssert.Contains(message, "Book");
+ StringAssert.Contains(message, "field");
+ StringAssert.Contains(message, "describe_entities");
+ }
+ }
+}
diff --git a/src/Service.Tests/Mcp/McpJsonHelperTests.cs b/src/Service.Tests/Mcp/McpJsonHelperTests.cs
new file mode 100644
index 0000000000..b7f503d807
--- /dev/null
+++ b/src/Service.Tests/Mcp/McpJsonHelperTests.cs
@@ -0,0 +1,107 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using System.Text.Json;
+using Azure.DataApiBuilder.Mcp.Utils;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.Mcp
+{
+ ///
+ /// Unit tests for covering JSON value conversion and
+ /// engine-result extraction helpers. Pure logic; no database required.
+ ///
+ [TestClass]
+ public class McpJsonHelperTests
+ {
+ private static JsonElement Value(string json) => JsonDocument.Parse(json).RootElement;
+
+ [TestMethod]
+ public void GetJsonValue_String_ReturnsString()
+ {
+ Assert.AreEqual("hello", McpJsonHelper.GetJsonValue(Value("\"hello\"")));
+ }
+
+ [TestMethod]
+ public void GetJsonValue_Number_ReturnsDecimal()
+ {
+ // McpJsonHelper prefers decimal for maximum precision.
+ Assert.AreEqual(42m, McpJsonHelper.GetJsonValue(Value("42")));
+ Assert.AreEqual(3.14m, McpJsonHelper.GetJsonValue(Value("3.14")));
+ }
+
+ [TestMethod]
+ public void GetJsonValue_Booleans_ReturnBool()
+ {
+ Assert.AreEqual(true, McpJsonHelper.GetJsonValue(Value("true")));
+ Assert.AreEqual(false, McpJsonHelper.GetJsonValue(Value("false")));
+ }
+
+ [TestMethod]
+ public void GetJsonValue_Null_ReturnsNull()
+ {
+ Assert.IsNull(McpJsonHelper.GetJsonValue(Value("null")));
+ }
+
+ [DataTestMethod]
+ [DataRow("[1, 2, 3]", DisplayName = "Array falls back to raw text")]
+ [DataRow(@"{ ""a"": 1 }", DisplayName = "Object falls back to raw text")]
+ public void GetJsonValue_ComplexTypes_ReturnRawText(string json)
+ {
+ object? result = McpJsonHelper.GetJsonValue(Value(json));
+
+ Assert.IsInstanceOfType(result, typeof(string));
+ }
+
+ [TestMethod]
+ public void ExtractValuesFromEngineResult_WithValueArray_ReturnsFirstItemProperties()
+ {
+ JsonElement engine = Value(@"{ ""value"": [ { ""id"": 1, ""title"": ""DAB"" }, { ""id"": 2 } ] }");
+
+ Dictionary result = McpJsonHelper.ExtractValuesFromEngineResult(engine);
+
+ Assert.AreEqual(2, result.Count);
+ Assert.AreEqual(1m, result["id"]);
+ Assert.AreEqual("DAB", result["title"]);
+ }
+
+ [TestMethod]
+ public void ExtractValuesFromEngineResult_EmptyValueArray_ReturnsEmpty()
+ {
+ JsonElement engine = Value(@"{ ""value"": [] }");
+
+ Dictionary result = McpJsonHelper.ExtractValuesFromEngineResult(engine);
+
+ Assert.AreEqual(0, result.Count);
+ }
+
+ [TestMethod]
+ public void ExtractValuesFromEngineResult_NoValueProperty_ReturnsEmpty()
+ {
+ JsonElement engine = Value(@"{ ""other"": 1 }");
+
+ Dictionary result = McpJsonHelper.ExtractValuesFromEngineResult(engine);
+
+ Assert.AreEqual(0, result.Count);
+ }
+
+ [TestMethod]
+ public void FormatKeyDetails_JoinsKeyValuePairs()
+ {
+ Dictionary keys = new() { ["id"] = 1, ["name"] = "abc" };
+
+ string formatted = McpJsonHelper.FormatKeyDetails(keys);
+
+ StringAssert.Contains(formatted, "id=1");
+ StringAssert.Contains(formatted, "name=abc");
+ StringAssert.Contains(formatted, ", ");
+ }
+
+ [TestMethod]
+ public void FormatKeyDetails_EmptyDictionary_ReturnsEmptyString()
+ {
+ Assert.AreEqual(string.Empty, McpJsonHelper.FormatKeyDetails(new Dictionary()));
+ }
+ }
+}
diff --git a/src/Service.Tests/Mcp/McpMetadataHelperTests.cs b/src/Service.Tests/Mcp/McpMetadataHelperTests.cs
new file mode 100644
index 0000000000..1602bdc6bc
--- /dev/null
+++ b/src/Service.Tests/Mcp/McpMetadataHelperTests.cs
@@ -0,0 +1,202 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Azure.DataApiBuilder.Config.DatabasePrimitives;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Services;
+using Azure.DataApiBuilder.Core.Services.MetadataProviders;
+using Azure.DataApiBuilder.Mcp.Utils;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Azure.DataApiBuilder.Service.Tests.Mcp
+{
+ ///
+ /// Unit tests for metadata-resolution branches using an
+ /// in-memory config and a mocked metadata provider factory (no database).
+ ///
+ [TestClass]
+ public class McpMetadataHelperTests
+ {
+ private const string ENTITY_NAME = "Book";
+
+ [DataTestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow(" ")]
+ public void TryResolveMetadata_NullOrEmptyEntityName_ReturnsFalse(string entityName)
+ {
+ RuntimeConfig config = CreateConfig(includeBookEntity: true);
+ IServiceProvider serviceProvider = CreateServiceProvider(registerFactory: true, includeBookMetadata: true);
+
+ bool result = McpMetadataHelper.TryResolveMetadata(
+ entityName, config, serviceProvider, out _, out _, out _, out string error);
+
+ Assert.IsFalse(result);
+ Assert.AreEqual("Entity name cannot be null or empty.", error);
+ }
+
+ [TestMethod]
+ public void TryResolveMetadata_FactoryNotRegistered_ReturnsFalse()
+ {
+ RuntimeConfig config = CreateConfig(includeBookEntity: true);
+ IServiceProvider serviceProvider = CreateServiceProvider(registerFactory: false, includeBookMetadata: false);
+
+ bool result = McpMetadataHelper.TryResolveMetadata(
+ ENTITY_NAME, config, serviceProvider, out _, out _, out _, out string error);
+
+ Assert.IsFalse(result);
+ Assert.AreEqual("Metadata provider factory is not registered.", error);
+ }
+
+ [TestMethod]
+ public void TryResolveMetadata_EntityNotInConfig_ReturnsFalse()
+ {
+ RuntimeConfig config = CreateConfig(includeBookEntity: false);
+ IServiceProvider serviceProvider = CreateServiceProvider(registerFactory: true, includeBookMetadata: false);
+
+ bool result = McpMetadataHelper.TryResolveMetadata(
+ "GhostEntity", config, serviceProvider, out _, out _, out _, out string error);
+
+ Assert.IsFalse(result);
+ StringAssert.Contains(error, "is not defined in the configuration");
+ }
+
+ [TestMethod]
+ public void TryResolveMetadata_EntityNotInMetadata_ReturnsFalse()
+ {
+ RuntimeConfig config = CreateConfig(includeBookEntity: true);
+ // Factory registered but the metadata provider has no entry for the entity.
+ IServiceProvider serviceProvider = CreateServiceProvider(registerFactory: true, includeBookMetadata: false);
+
+ bool result = McpMetadataHelper.TryResolveMetadata(
+ ENTITY_NAME, config, serviceProvider, out _, out _, out _, out string error);
+
+ Assert.IsFalse(result);
+ StringAssert.Contains(error, "is not defined in the configuration");
+ }
+
+ [TestMethod]
+ public void TryResolveMetadata_Success_ReturnsTrue()
+ {
+ RuntimeConfig config = CreateConfig(includeBookEntity: true);
+ IServiceProvider serviceProvider = CreateServiceProvider(registerFactory: true, includeBookMetadata: true);
+
+ bool result = McpMetadataHelper.TryResolveMetadata(
+ ENTITY_NAME, config, serviceProvider,
+ out ISqlMetadataProvider provider, out DatabaseObject dbObject, out string dataSourceName, out string error);
+
+ Assert.IsTrue(result);
+ Assert.IsNotNull(provider);
+ Assert.IsNotNull(dbObject);
+ Assert.IsFalse(string.IsNullOrEmpty(dataSourceName));
+ Assert.AreEqual(string.Empty, error);
+ }
+
+ [TestMethod]
+ public void TryResolveMetadata_CancelledToken_Throws()
+ {
+ RuntimeConfig config = CreateConfig(includeBookEntity: true);
+ IServiceProvider serviceProvider = CreateServiceProvider(registerFactory: true, includeBookMetadata: true);
+ using CancellationTokenSource cts = new();
+ cts.Cancel();
+
+ Assert.ThrowsException(() => McpMetadataHelper.TryResolveMetadata(
+ ENTITY_NAME, config, serviceProvider, out _, out _, out _, out _, cts.Token));
+ }
+
+ [TestMethod]
+ public void TryResolveDatabaseObject_Success_ReturnsDbObject()
+ {
+ RuntimeConfig config = CreateConfig(includeBookEntity: true);
+ IServiceProvider serviceProvider = CreateServiceProvider(registerFactory: true, includeBookMetadata: true);
+
+ DatabaseObject? dbObject = McpMetadataHelper.TryResolveDatabaseObject(
+ ENTITY_NAME, config, serviceProvider, out string error);
+
+ Assert.IsNotNull(dbObject);
+ Assert.AreEqual(string.Empty, error);
+ }
+
+ [TestMethod]
+ public void TryResolveDatabaseObject_Failure_ReturnsNull()
+ {
+ RuntimeConfig config = CreateConfig(includeBookEntity: true);
+ IServiceProvider serviceProvider = CreateServiceProvider(registerFactory: true, includeBookMetadata: false);
+
+ DatabaseObject? dbObject = McpMetadataHelper.TryResolveDatabaseObject(
+ ENTITY_NAME, config, serviceProvider, out string error);
+
+ Assert.IsNull(dbObject);
+ StringAssert.Contains(error, "is not defined in the configuration");
+ }
+
+ #region Helpers
+
+ private static RuntimeConfig CreateConfig(bool includeBookEntity)
+ {
+ Dictionary entities = new();
+ if (includeBookEntity)
+ {
+ entities[ENTITY_NAME] = new Entity(
+ Source: new(ENTITY_NAME, EntitySourceType.Table, null, null),
+ GraphQL: new(ENTITY_NAME, ENTITY_NAME + "s"),
+ Fields: null,
+ Rest: new(Enabled: true),
+ Permissions: new[]
+ {
+ new EntityPermission(Role: "anonymous", Actions: new[]
+ {
+ new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null)
+ })
+ },
+ Mappings: null,
+ Relationships: null,
+ Mcp: null);
+ }
+
+ return new RuntimeConfig(
+ Schema: "test-schema",
+ DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "Server=test;", Options: null),
+ Runtime: new(
+ Rest: new(),
+ GraphQL: new(),
+ Mcp: null,
+ Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)),
+ Entities: new(entities));
+ }
+
+ private static IServiceProvider CreateServiceProvider(bool registerFactory, bool includeBookMetadata)
+ {
+ ServiceCollection services = new();
+
+ if (registerFactory)
+ {
+ Dictionary dbObjects = new();
+ if (includeBookMetadata)
+ {
+ dbObjects[ENTITY_NAME] = new DatabaseTable(schemaName: "dbo", tableName: "books")
+ {
+ SourceType = EntitySourceType.Table,
+ TableDefinition = new SourceDefinition()
+ };
+ }
+
+ Mock mockProvider = new();
+ mockProvider.Setup(x => x.EntityToDatabaseObject).Returns(dbObjects);
+
+ Mock mockFactory = new();
+ mockFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(mockProvider.Object);
+ services.AddSingleton(mockFactory.Object);
+ }
+
+ return services.BuildServiceProvider();
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Service.Tests/Mcp/McpResponseBuilderTests.cs b/src/Service.Tests/Mcp/McpResponseBuilderTests.cs
new file mode 100644
index 0000000000..6b502a9d63
--- /dev/null
+++ b/src/Service.Tests/Mcp/McpResponseBuilderTests.cs
@@ -0,0 +1,125 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using Azure.DataApiBuilder.Mcp.Utils;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ModelContextProtocol.Protocol;
+
+namespace Azure.DataApiBuilder.Service.Tests.Mcp
+{
+ ///
+ /// Unit tests for covering success/error result
+ /// construction and IActionResult JSON extraction. Pure logic; no database required.
+ ///
+ [TestClass]
+ public class McpResponseBuilderTests
+ {
+ private static JsonElement ParseContent(CallToolResult result)
+ {
+ TextContentBlock block = (TextContentBlock)result.Content[0];
+ return JsonDocument.Parse(block.Text).RootElement;
+ }
+
+ [TestMethod]
+ public void BuildSuccessResult_AddsStatusAndData()
+ {
+ Dictionary data = new() { ["id"] = 1, ["name"] = "DAB" };
+
+ CallToolResult result = McpResponseBuilder.BuildSuccessResult(data);
+
+ Assert.IsTrue(result.IsError != true);
+ JsonElement content = ParseContent(result);
+ Assert.AreEqual("success", content.GetProperty("status").GetString());
+ Assert.AreEqual(1, content.GetProperty("id").GetInt32());
+ Assert.AreEqual("DAB", content.GetProperty("name").GetString());
+ }
+
+ [TestMethod]
+ public void BuildErrorResult_SetsIsErrorAndStructure()
+ {
+ CallToolResult result = McpResponseBuilder.BuildErrorResult(
+ "read_records", "InvalidArguments", "Something went wrong");
+
+ Assert.IsTrue(result.IsError == true);
+ JsonElement content = ParseContent(result);
+ Assert.AreEqual("read_records", content.GetProperty("toolName").GetString());
+ Assert.AreEqual("error", content.GetProperty("status").GetString());
+ JsonElement error = content.GetProperty("error");
+ Assert.AreEqual("InvalidArguments", error.GetProperty("type").GetString());
+ Assert.AreEqual("Something went wrong", error.GetProperty("message").GetString());
+ }
+
+ [TestMethod]
+ public void ExtractResultJson_ObjectResultWithJsonElement_ReturnsRawText()
+ {
+ JsonElement je = JsonDocument.Parse(@"{ ""a"": 1 }").RootElement;
+ ObjectResult objResult = new(je);
+
+ string json = McpResponseBuilder.ExtractResultJson(objResult);
+
+ Assert.AreEqual(@"{ ""a"": 1 }".Replace(" ", ""), json.Replace(" ", ""));
+ }
+
+ [TestMethod]
+ public void ExtractResultJson_ObjectResultWithJsonDocument_ReturnsRawText()
+ {
+ JsonDocument jd = JsonDocument.Parse(@"{ ""b"": 2 }");
+ ObjectResult objResult = new(jd);
+
+ string json = McpResponseBuilder.ExtractResultJson(objResult);
+
+ Assert.AreEqual(@"{ ""b"": 2 }".Replace(" ", ""), json.Replace(" ", ""));
+ }
+
+ [TestMethod]
+ public void ExtractResultJson_ObjectResultWithPlainObject_SerializesValue()
+ {
+ ObjectResult objResult = new(new Dictionary { ["c"] = 3 });
+
+ string json = McpResponseBuilder.ExtractResultJson(objResult);
+
+ StringAssert.Contains(json, "\"c\"");
+ StringAssert.Contains(json, "3");
+ }
+
+ [TestMethod]
+ public void ExtractResultJson_ContentResultWithContent_ReturnsContent()
+ {
+ ContentResult content = new() { Content = "{\"x\":1}" };
+
+ Assert.AreEqual("{\"x\":1}", McpResponseBuilder.ExtractResultJson(content));
+ }
+
+ [TestMethod]
+ public void ExtractResultJson_ContentResultEmpty_ReturnsEmptyObject()
+ {
+ ContentResult content = new() { Content = " " };
+
+ Assert.AreEqual("{}", McpResponseBuilder.ExtractResultJson(content));
+ }
+
+ [TestMethod]
+ public void ExtractResultJson_Null_ReturnsEmptyObject()
+ {
+ Assert.AreEqual("{}", McpResponseBuilder.ExtractResultJson(null));
+ }
+
+ [TestMethod]
+ public void ExtractResultJson_UnsupportedResult_ReturnsEmptyObject()
+ {
+ Assert.AreEqual("{}", McpResponseBuilder.ExtractResultJson(new NoContentResult()));
+ }
+
+ [TestMethod]
+ public void GetJsonValue_Number_PreservesNumericValue()
+ {
+ JsonElement number = JsonDocument.Parse("42").RootElement;
+
+ Assert.AreEqual(42m, Convert.ToDecimal(McpResponseBuilder.GetJsonValue(number)));
+ }
+ }
+}
diff --git a/src/Service.Tests/Mcp/McpTelemetryHelperTests.cs b/src/Service.Tests/Mcp/McpTelemetryHelperTests.cs
new file mode 100644
index 0000000000..29abd432ac
--- /dev/null
+++ b/src/Service.Tests/Mcp/McpTelemetryHelperTests.cs
@@ -0,0 +1,179 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Net;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Mcp.Model;
+using Azure.DataApiBuilder.Mcp.Utils;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ModelContextProtocol.Protocol;
+using static Azure.DataApiBuilder.Mcp.Model.McpEnums;
+
+namespace Azure.DataApiBuilder.Service.Tests.Mcp
+{
+ ///
+ /// Unit tests for McpTelemetryHelper (operation inference, exception→error-code mapping,
+ /// and the telemetry execution wrapper). Pure logic; no database required.
+ ///
+ [TestClass]
+ public class McpTelemetryHelperTests
+ {
+ [DataTestMethod]
+ [DataRow("read_records", "read")]
+ [DataRow("create_record", "create")]
+ [DataRow("update_record", "update")]
+ [DataRow("delete_record", "delete")]
+ [DataRow("describe_entities", "describe")]
+ [DataRow("execute_entity", "execute")]
+ [DataRow("aggregate_records", "aggregate")]
+ [DataRow("some_unknown_tool", "execute")]
+ public void InferOperationFromTool_BuiltIn_MapsToolNameToOperation(string toolName, string expected)
+ {
+ FakeTool tool = new(ToolType.BuiltIn);
+
+ Assert.AreEqual(expected, McpTelemetryHelper.InferOperationFromTool(tool, toolName));
+ }
+
+ [TestMethod]
+ public void InferOperationFromTool_CustomTool_AlwaysReturnsExecute()
+ {
+ FakeTool tool = new(ToolType.Custom);
+
+ Assert.AreEqual("execute", McpTelemetryHelper.InferOperationFromTool(tool, "anything"));
+ }
+
+ [TestMethod]
+ public void MapExceptionToErrorCode_MapsKnownExceptionTypes()
+ {
+ Assert.AreEqual(McpTelemetryErrorCodes.OPERATION_CANCELLED, McpTelemetryHelper.MapExceptionToErrorCode(new OperationCanceledException()));
+ Assert.AreEqual(McpTelemetryErrorCodes.OPERATION_TIMEOUT, McpTelemetryHelper.MapExceptionToErrorCode(new TimeoutException()));
+ Assert.AreEqual(McpTelemetryErrorCodes.AUTHORIZATION_FAILED, McpTelemetryHelper.MapExceptionToErrorCode(new UnauthorizedAccessException()));
+ Assert.AreEqual(McpTelemetryErrorCodes.DATABASE_ERROR, McpTelemetryHelper.MapExceptionToErrorCode(new FakeDbException()));
+ Assert.AreEqual(McpTelemetryErrorCodes.INVALID_REQUEST, McpTelemetryHelper.MapExceptionToErrorCode(new ArgumentException("bad")));
+ Assert.AreEqual(McpTelemetryErrorCodes.EXECUTION_FAILED, McpTelemetryHelper.MapExceptionToErrorCode(new InvalidOperationException()));
+ }
+
+ [TestMethod]
+ public void MapExceptionToErrorCode_MapsDataApiBuilderSubStatusCodes()
+ {
+ DataApiBuilderException authN = new("x", HttpStatusCode.Unauthorized, DataApiBuilderException.SubStatusCodes.AuthenticationChallenge);
+ DataApiBuilderException authZ = new("x", HttpStatusCode.Forbidden, DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed);
+
+ Assert.AreEqual(McpTelemetryErrorCodes.AUTHENTICATION_FAILED, McpTelemetryHelper.MapExceptionToErrorCode(authN));
+ Assert.AreEqual(McpTelemetryErrorCodes.AUTHORIZATION_FAILED, McpTelemetryHelper.MapExceptionToErrorCode(authZ));
+ }
+
+ [TestMethod]
+ public async Task ExecuteWithTelemetryAsync_SuccessResult_IsReturned()
+ {
+ CallToolResult success = new()
+ {
+ Content = new List { new TextContentBlock { Text = "{\"status\":\"success\"}" } }
+ };
+ FakeTool tool = new(ToolType.BuiltIn, success);
+ using JsonDocument args = JsonDocument.Parse("{\"entity\":\"Book\"}");
+
+ CallToolResult result = await McpTelemetryHelper.ExecuteWithTelemetryAsync(
+ tool, "read_records", args, EmptyProvider(), CancellationToken.None);
+
+ Assert.IsFalse(result.IsError == true);
+ }
+
+ [TestMethod]
+ public async Task ExecuteWithTelemetryAsync_ErrorResultWithCodeAndMessage_IsReturned()
+ {
+ CallToolResult error = new()
+ {
+ IsError = true,
+ Content = new List { new TextContentBlock { Text = "{\"code\":\"E1\",\"message\":\"boom\"}" } }
+ };
+ FakeTool tool = new(ToolType.BuiltIn, error);
+ using JsonDocument args = JsonDocument.Parse("{\"entity\":\"Book\"}");
+
+ CallToolResult result = await McpTelemetryHelper.ExecuteWithTelemetryAsync(
+ tool, "create_record", args, EmptyProvider(), CancellationToken.None);
+
+ Assert.IsTrue(result.IsError == true);
+ }
+
+ [TestMethod]
+ public async Task ExecuteWithTelemetryAsync_ErrorResultNonJsonContent_IsReturned()
+ {
+ CallToolResult error = new()
+ {
+ IsError = true,
+ Content = new List { new TextContentBlock { Text = "not-json" } }
+ };
+ FakeTool tool = new(ToolType.BuiltIn, error);
+
+ CallToolResult result = await McpTelemetryHelper.ExecuteWithTelemetryAsync(
+ tool, "delete_record", arguments: null, EmptyProvider(), CancellationToken.None);
+
+ Assert.IsTrue(result.IsError == true);
+ }
+
+ [TestMethod]
+ public async Task ExecuteWithTelemetryAsync_ToolThrows_Rethrows()
+ {
+ FakeTool tool = new(ToolType.BuiltIn, throwOnExecute: new ArgumentException("bad"));
+
+ await Assert.ThrowsExceptionAsync(async () =>
+ await McpTelemetryHelper.ExecuteWithTelemetryAsync(
+ tool, "update_record", arguments: null, EmptyProvider(), CancellationToken.None));
+ }
+
+ private static IServiceProvider EmptyProvider() => new ServiceCollection().BuildServiceProvider();
+
+ private sealed class FakeTool : IMcpTool
+ {
+ private readonly CallToolResult? _result;
+ private readonly Exception? _throw;
+
+ public FakeTool(ToolType toolType, CallToolResult? result = null, Exception? throwOnExecute = null)
+ {
+ ToolType = toolType;
+ _result = result;
+ _throw = throwOnExecute;
+ }
+
+ public ToolType ToolType { get; }
+
+ public bool IsEnabled(RuntimeConfig config) => true;
+
+ public Tool GetToolMetadata() => new() { Name = "fake" };
+
+ public Task ExecuteAsync(JsonDocument? arguments, IServiceProvider serviceProvider, CancellationToken cancellationToken)
+ {
+ if (_throw is not null)
+ {
+ throw _throw;
+ }
+
+ return Task.FromResult(_result ?? new CallToolResult());
+ }
+ }
+
+ private sealed class FakeDbException : DbException
+ {
+ public FakeDbException() : base("fake db error")
+ {
+ }
+
+ public FakeDbException(string message) : base(message)
+ {
+ }
+
+ public FakeDbException(string message, Exception innerException) : base(message, innerException)
+ {
+ }
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/AKVRetryPolicyOptionsConverterTests.cs b/src/Service.Tests/UnitTests/AKVRetryPolicyOptionsConverterTests.cs
new file mode 100644
index 0000000000..b03299fc78
--- /dev/null
+++ b/src/Service.Tests/UnitTests/AKVRetryPolicyOptionsConverterTests.cs
@@ -0,0 +1,153 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#nullable disable
+
+using System.Text.Json;
+using Azure.DataApiBuilder.Config;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json.Linq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for AKVRetryPolicyOptionsConverterFactory covering JSON
+ /// read/write of Azure Key Vault retry policy options.
+ ///
+ [TestClass]
+ public class AKVRetryPolicyOptionsConverterTests
+ {
+ private static JsonSerializerOptions GetOptions()
+ {
+ return RuntimeConfigLoader.GetSerializationOptions();
+ }
+
+ [TestMethod]
+ public void Deserialize_AllProperties_AreReadCorrectly()
+ {
+ string json = @"{
+ ""mode"": ""Fixed"",
+ ""max-count"": 5,
+ ""delay-seconds"": 2,
+ ""max-delay-seconds"": 30,
+ ""network-timeout-seconds"": 45
+ }";
+
+ AKVRetryPolicyOptions options = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(options);
+ Assert.AreEqual(AKVRetryPolicyMode.Fixed, options.Mode);
+ Assert.AreEqual(5, options.MaxCount);
+ Assert.AreEqual(2, options.DelaySeconds);
+ Assert.AreEqual(30, options.MaxDelaySeconds);
+ Assert.AreEqual(45, options.NetworkTimeoutSeconds);
+ Assert.IsTrue(options.UserProvidedMode);
+ Assert.IsTrue(options.UserProvidedMaxCount);
+ Assert.IsTrue(options.UserProvidedDelaySeconds);
+ Assert.IsTrue(options.UserProvidedMaxDelaySeconds);
+ Assert.IsTrue(options.UserProvidedNetworkTimeoutSeconds);
+ }
+
+ [TestMethod]
+ public void Deserialize_NullValues_FallBackToDefaults()
+ {
+ string json = @"{
+ ""mode"": null,
+ ""max-count"": null,
+ ""delay-seconds"": null,
+ ""max-delay-seconds"": null,
+ ""network-timeout-seconds"": null
+ }";
+
+ AKVRetryPolicyOptions options = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(options);
+ Assert.AreEqual(AKVRetryPolicyOptions.DEFAULT_MODE, options.Mode);
+ Assert.AreEqual(AKVRetryPolicyOptions.DEFAULT_MAX_COUNT, options.MaxCount);
+ Assert.AreEqual(AKVRetryPolicyOptions.DEFAULT_DELAY_SECONDS, options.DelaySeconds);
+ Assert.AreEqual(AKVRetryPolicyOptions.DEFAULT_MAX_DELAY_SECONDS, options.MaxDelaySeconds);
+ Assert.AreEqual(AKVRetryPolicyOptions.DEFAULT_NETWORK_TIMEOUT_SECONDS, options.NetworkTimeoutSeconds);
+ Assert.IsFalse(options.UserProvidedMode);
+ Assert.IsFalse(options.UserProvidedMaxCount);
+ Assert.IsFalse(options.UserProvidedDelaySeconds);
+ Assert.IsFalse(options.UserProvidedMaxDelaySeconds);
+ Assert.IsFalse(options.UserProvidedNetworkTimeoutSeconds);
+ }
+
+ [TestMethod]
+ public void Deserialize_EmptyObject_ReturnsDefaults()
+ {
+ AKVRetryPolicyOptions options = JsonSerializer.Deserialize("{}", GetOptions());
+
+ Assert.IsNotNull(options);
+ Assert.IsFalse(options.UserProvidedMode);
+ Assert.IsFalse(options.UserProvidedMaxCount);
+ }
+
+ [DataTestMethod]
+ [DataRow(@"{ ""max-count"": -1 }", DisplayName = "Negative max-count")]
+ [DataRow(@"{ ""delay-seconds"": 0 }", DisplayName = "Zero delay-seconds")]
+ [DataRow(@"{ ""max-delay-seconds"": 0 }", DisplayName = "Zero max-delay-seconds")]
+ [DataRow(@"{ ""network-timeout-seconds"": -5 }", DisplayName = "Negative network-timeout-seconds")]
+ public void Deserialize_InvalidValues_ThrowJsonException(string json)
+ {
+ Assert.ThrowsException(
+ () => JsonSerializer.Deserialize(json, GetOptions()));
+ }
+
+ [TestMethod]
+ public void Deserialize_NonObjectToken_ThrowsJsonException()
+ {
+ Assert.ThrowsException(
+ () => JsonSerializer.Deserialize("\"not-an-object\"", GetOptions()));
+ }
+
+ [TestMethod]
+ public void Serialize_OnlyWritesUserProvidedProperties()
+ {
+ AKVRetryPolicyOptions options = new(mode: AKVRetryPolicyMode.Exponential, maxCount: 7);
+
+ string json = JsonSerializer.Serialize(options, GetOptions());
+ JObject jObject = JObject.Parse(json);
+
+ Assert.IsTrue(jObject.ContainsKey("mode"));
+ Assert.IsTrue(jObject.ContainsKey("max-count"));
+ Assert.IsFalse(jObject.ContainsKey("delay-seconds"));
+ Assert.IsFalse(jObject.ContainsKey("max-delay-seconds"));
+ Assert.IsFalse(jObject.ContainsKey("network-timeout-seconds"));
+ Assert.AreEqual(7, jObject["max-count"].Value());
+ }
+
+ [TestMethod]
+ public void Serialize_NoUserProvidedProperties_WritesEmptyObject()
+ {
+ AKVRetryPolicyOptions options = new();
+
+ string json = JsonSerializer.Serialize(options, GetOptions());
+ JObject jObject = JObject.Parse(json);
+
+ Assert.AreEqual(0, jObject.Count);
+ }
+
+ [TestMethod]
+ public void RoundTrip_PreservesUserProvidedValues()
+ {
+ AKVRetryPolicyOptions original = new(
+ mode: AKVRetryPolicyMode.Fixed,
+ maxCount: 4,
+ delaySeconds: 3,
+ maxDelaySeconds: 20,
+ networkTimeoutSeconds: 50);
+
+ string json = JsonSerializer.Serialize(original, GetOptions());
+ AKVRetryPolicyOptions result = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.AreEqual(original.Mode, result.Mode);
+ Assert.AreEqual(original.MaxCount, result.MaxCount);
+ Assert.AreEqual(original.DelaySeconds, result.DelaySeconds);
+ Assert.AreEqual(original.MaxDelaySeconds, result.MaxDelaySeconds);
+ Assert.AreEqual(original.NetworkTimeoutSeconds, result.NetworkTimeoutSeconds);
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/AzureKeyVaultOptionsConverterTests.cs b/src/Service.Tests/UnitTests/AzureKeyVaultOptionsConverterTests.cs
new file mode 100644
index 0000000000..a2f054e0c6
--- /dev/null
+++ b/src/Service.Tests/UnitTests/AzureKeyVaultOptionsConverterTests.cs
@@ -0,0 +1,134 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#nullable disable
+
+using System.Text.Json;
+using Azure.DataApiBuilder.Config;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json.Linq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for AzureKeyVaultOptionsConverterFactory covering JSON
+ /// read/write of Azure Key Vault options including nested retry policy.
+ ///
+ [TestClass]
+ public class AzureKeyVaultOptionsConverterTests
+ {
+ private static JsonSerializerOptions GetOptions()
+ {
+ return RuntimeConfigLoader.GetSerializationOptions();
+ }
+
+ [TestMethod]
+ public void Deserialize_EndpointAndRetryPolicy_AreReadCorrectly()
+ {
+ string json = @"{
+ ""endpoint"": ""https://my-vault.vault.azure.net/"",
+ ""retry-policy"": {
+ ""mode"": ""Fixed"",
+ ""max-count"": 5
+ }
+ }";
+
+ AzureKeyVaultOptions options = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(options);
+ Assert.AreEqual("https://my-vault.vault.azure.net/", options.Endpoint);
+ Assert.IsTrue(options.UserProvidedEndpoint);
+ Assert.IsTrue(options.UserProvidedRetryPolicy);
+ Assert.IsNotNull(options.RetryPolicy);
+ Assert.AreEqual(AKVRetryPolicyMode.Fixed, options.RetryPolicy.Mode);
+ Assert.AreEqual(5, options.RetryPolicy.MaxCount);
+ }
+
+ [TestMethod]
+ public void Deserialize_OnlyEndpoint_RetryPolicyIsNull()
+ {
+ string json = @"{ ""endpoint"": ""https://my-vault.vault.azure.net/"" }";
+
+ AzureKeyVaultOptions options = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(options);
+ Assert.IsTrue(options.UserProvidedEndpoint);
+ Assert.IsFalse(options.UserProvidedRetryPolicy);
+ Assert.IsNull(options.RetryPolicy);
+ }
+
+ [TestMethod]
+ public void Deserialize_EmptyObject_HasNoUserProvidedValues()
+ {
+ AzureKeyVaultOptions options = JsonSerializer.Deserialize("{}", GetOptions());
+
+ Assert.IsNotNull(options);
+ Assert.IsFalse(options.UserProvidedEndpoint);
+ Assert.IsFalse(options.UserProvidedRetryPolicy);
+ }
+
+ [TestMethod]
+ public void Deserialize_NullToken_ReturnsNull()
+ {
+ AzureKeyVaultOptions options = JsonSerializer.Deserialize("null", GetOptions());
+
+ Assert.IsNull(options);
+ }
+
+ [TestMethod]
+ public void Deserialize_UnexpectedProperty_ThrowsJsonException()
+ {
+ string json = @"{ ""unexpected"": ""value"" }";
+
+ Assert.ThrowsException(
+ () => JsonSerializer.Deserialize(json, GetOptions()));
+ }
+
+ [TestMethod]
+ public void Deserialize_NonObjectToken_ThrowsJsonException()
+ {
+ Assert.ThrowsException(
+ () => JsonSerializer.Deserialize("\"not-an-object\"", GetOptions()));
+ }
+
+ [TestMethod]
+ public void Serialize_OnlyWritesUserProvidedProperties()
+ {
+ AzureKeyVaultOptions options = new(endpoint: "https://my-vault.vault.azure.net/");
+
+ string json = JsonSerializer.Serialize(options, GetOptions());
+ JObject jObject = JObject.Parse(json);
+
+ Assert.IsTrue(jObject.ContainsKey("endpoint"));
+ Assert.IsFalse(jObject.ContainsKey("retry-policy"));
+ }
+
+ [TestMethod]
+ public void Serialize_NoUserProvidedProperties_WritesEmptyObject()
+ {
+ AzureKeyVaultOptions options = new();
+
+ string json = JsonSerializer.Serialize(options, GetOptions());
+ JObject jObject = JObject.Parse(json);
+
+ Assert.AreEqual(0, jObject.Count);
+ }
+
+ [TestMethod]
+ public void RoundTrip_PreservesEndpointAndRetryPolicy()
+ {
+ AzureKeyVaultOptions original = new(
+ endpoint: "https://my-vault.vault.azure.net/",
+ retryPolicy: new AKVRetryPolicyOptions(mode: AKVRetryPolicyMode.Exponential, maxCount: 6));
+
+ string json = JsonSerializer.Serialize(original, GetOptions());
+ AzureKeyVaultOptions result = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.AreEqual(original.Endpoint, result.Endpoint);
+ Assert.IsNotNull(result.RetryPolicy);
+ Assert.AreEqual(original.RetryPolicy.Mode, result.RetryPolicy.Mode);
+ Assert.AreEqual(original.RetryPolicy.MaxCount, result.RetryPolicy.MaxCount);
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/BoolJsonConverterTests.cs b/src/Service.Tests/UnitTests/BoolJsonConverterTests.cs
new file mode 100644
index 0000000000..93e38493a1
--- /dev/null
+++ b/src/Service.Tests/UnitTests/BoolJsonConverterTests.cs
@@ -0,0 +1,61 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#nullable disable
+
+using System.Text.Json;
+using Azure.DataApiBuilder.Config;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for BoolJsonConverter which accepts boolean literals as well as
+ /// string and numeric representations ("true"/"false"/"1"/"0").
+ ///
+ [TestClass]
+ public class BoolJsonConverterTests
+ {
+ private static JsonSerializerOptions GetOptions()
+ {
+ return RuntimeConfigLoader.GetSerializationOptions();
+ }
+
+ [DataTestMethod]
+ [DataRow("true", true, DisplayName = "Boolean literal true")]
+ [DataRow("false", false, DisplayName = "Boolean literal false")]
+ [DataRow("\"true\"", true, DisplayName = "String true")]
+ [DataRow("\"false\"", false, DisplayName = "String false")]
+ [DataRow("\"True\"", true, DisplayName = "String True mixed case")]
+ [DataRow("\"1\"", true, DisplayName = "String 1")]
+ [DataRow("\"0\"", false, DisplayName = "String 0")]
+ [DataRow("1", true, DisplayName = "Number 1")]
+ [DataRow("0", false, DisplayName = "Number 0")]
+ public void Deserialize_ValidRepresentations_AreParsed(string json, bool expected)
+ {
+ bool result = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.AreEqual(expected, result);
+ }
+
+ [DataTestMethod]
+ [DataRow("\"maybe\"", DisplayName = "Invalid string")]
+ [DataRow("2", DisplayName = "Invalid number")]
+ [DataRow("null", DisplayName = "Null token")]
+ public void Deserialize_InvalidValues_ThrowJsonException(string json)
+ {
+ Assert.ThrowsException(
+ () => JsonSerializer.Deserialize(json, GetOptions()));
+ }
+
+ [DataTestMethod]
+ [DataRow(true, "true")]
+ [DataRow(false, "false")]
+ public void Serialize_WritesBooleanLiteral(bool value, string expected)
+ {
+ string json = JsonSerializer.Serialize(value, GetOptions());
+
+ Assert.AreEqual(expected, json);
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/CompressionOptionsConverterTests.cs b/src/Service.Tests/UnitTests/CompressionOptionsConverterTests.cs
new file mode 100644
index 0000000000..cad0a2f0a2
--- /dev/null
+++ b/src/Service.Tests/UnitTests/CompressionOptionsConverterTests.cs
@@ -0,0 +1,122 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#nullable disable
+
+using System.Text.Json;
+using Azure.DataApiBuilder.Config;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json.Linq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for CompressionOptionsConverterFactory covering JSON
+ /// read/write of HTTP response compression options.
+ ///
+ [TestClass]
+ public class CompressionOptionsConverterTests
+ {
+ private static JsonSerializerOptions GetOptions()
+ {
+ return RuntimeConfigLoader.GetSerializationOptions();
+ }
+
+ [DataTestMethod]
+ [DataRow("optimal", CompressionLevel.Optimal)]
+ [DataRow("fastest", CompressionLevel.Fastest)]
+ [DataRow("none", CompressionLevel.None)]
+ [DataRow("OPTIMAL", CompressionLevel.Optimal)]
+ public void Deserialize_ValidLevel_IsReadCorrectly(string levelStr, CompressionLevel expected)
+ {
+ string json = $@"{{ ""level"": ""{levelStr}"" }}";
+
+ CompressionOptions options = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(options);
+ Assert.AreEqual(expected, options.Level);
+ Assert.IsTrue(options.UserProvidedLevel);
+ }
+
+ [TestMethod]
+ public void Deserialize_NoLevel_UsesDefaultAndNotUserProvided()
+ {
+ CompressionOptions options = JsonSerializer.Deserialize("{}", GetOptions());
+
+ Assert.IsNotNull(options);
+ Assert.AreEqual(CompressionOptions.DEFAULT_LEVEL, options.Level);
+ Assert.IsFalse(options.UserProvidedLevel);
+ }
+
+ [TestMethod]
+ public void Deserialize_UnknownProperty_IsSkipped()
+ {
+ string json = @"{ ""unknown"": { ""nested"": true }, ""level"": ""fastest"" }";
+
+ CompressionOptions options = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(options);
+ Assert.AreEqual(CompressionLevel.Fastest, options.Level);
+ }
+
+ [TestMethod]
+ public void Deserialize_InvalidLevel_ThrowsJsonException()
+ {
+ string json = @"{ ""level"": ""super-fast"" }";
+
+ Assert.ThrowsException(
+ () => JsonSerializer.Deserialize(json, GetOptions()));
+ }
+
+ [TestMethod]
+ public void Deserialize_NullToken_ReturnsNull()
+ {
+ CompressionOptions options = JsonSerializer.Deserialize("null", GetOptions());
+
+ Assert.IsNull(options);
+ }
+
+ [TestMethod]
+ public void Deserialize_NonObjectToken_ThrowsJsonException()
+ {
+ Assert.ThrowsException(
+ () => JsonSerializer.Deserialize("\"not-an-object\"", GetOptions()));
+ }
+
+ [TestMethod]
+ public void Serialize_UserProvidedLevel_WritesLowercaseLevel()
+ {
+ CompressionOptions options = new(CompressionLevel.Fastest);
+
+ string json = JsonSerializer.Serialize(options, GetOptions());
+ JObject jObject = JObject.Parse(json);
+
+ Assert.IsTrue(jObject.ContainsKey("level"));
+ Assert.AreEqual("fastest", jObject["level"].Value());
+ }
+
+ [TestMethod]
+ public void Serialize_NotUserProvided_WritesEmptyObject()
+ {
+ CompressionOptions options = new();
+
+ string json = JsonSerializer.Serialize(options, GetOptions());
+ JObject jObject = JObject.Parse(json);
+
+ Assert.AreEqual(0, jObject.Count);
+ }
+
+ [TestMethod]
+ public void RoundTrip_PreservesLevel()
+ {
+ CompressionOptions original = new(CompressionLevel.None);
+
+ string json = JsonSerializer.Serialize(original, GetOptions());
+ CompressionOptions result = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.AreEqual(original.Level, result.Level);
+ Assert.IsTrue(result.UserProvidedLevel);
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/DeserializationVariableReplacementSettingsTests.cs b/src/Service.Tests/UnitTests/DeserializationVariableReplacementSettingsTests.cs
new file mode 100644
index 0000000000..6548b5d84a
--- /dev/null
+++ b/src/Service.Tests/UnitTests/DeserializationVariableReplacementSettingsTests.cs
@@ -0,0 +1,221 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.IO;
+using System.Text.RegularExpressions;
+using Azure.DataApiBuilder.Config;
+using Azure.DataApiBuilder.Config.Converters;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for covering environment
+ /// variable replacement, Azure Key Vault local-file secret resolution, and remote client
+ /// construction (no network access). Pure logic; no database required.
+ ///
+ [TestClass]
+ public class DeserializationVariableReplacementSettingsTests
+ {
+ private const string ENV_VAR = "DAB_TEST_REPLACEMENT_VAR";
+
+ ///
+ /// Applies the first matching replacement strategy to the given input.
+ ///
+ private static string ApplyFirstMatch(DeserializationVariableReplacementSettings settings, string input)
+ {
+ foreach (System.Collections.Generic.KeyValuePair> strategy in settings.ReplacementStrategies)
+ {
+ Match match = strategy.Key.Match(input);
+ if (match.Success)
+ {
+ return strategy.Value(match);
+ }
+ }
+
+ return input;
+ }
+
+ [TestMethod]
+ public void Constructor_NoReplacement_RegistersNoStrategies()
+ {
+ DeserializationVariableReplacementSettings settings = new();
+
+ Assert.AreEqual(0, settings.ReplacementStrategies.Count);
+ }
+
+ [TestMethod]
+ public void Constructor_EnvReplacementEnabled_RegistersOneStrategy()
+ {
+ DeserializationVariableReplacementSettings settings = new(doReplaceEnvVar: true);
+
+ Assert.AreEqual(1, settings.ReplacementStrategies.Count);
+ Assert.IsTrue(settings.DoReplaceEnvVar);
+ }
+
+ [TestMethod]
+ public void EnvReplacement_VariableSet_ReturnsValue()
+ {
+ string? original = Environment.GetEnvironmentVariable(ENV_VAR);
+ try
+ {
+ Environment.SetEnvironmentVariable(ENV_VAR, "resolved-value");
+ DeserializationVariableReplacementSettings settings = new(doReplaceEnvVar: true);
+
+ string result = ApplyFirstMatch(settings, $"@env('{ENV_VAR}')");
+
+ Assert.AreEqual("resolved-value", result);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(ENV_VAR, original);
+ }
+ }
+
+ [TestMethod]
+ public void EnvReplacement_MissingVariable_ThrowMode_Throws()
+ {
+ string? original = Environment.GetEnvironmentVariable(ENV_VAR);
+ try
+ {
+ Environment.SetEnvironmentVariable(ENV_VAR, null);
+ DeserializationVariableReplacementSettings settings = new(
+ doReplaceEnvVar: true,
+ envFailureMode: EnvironmentVariableReplacementFailureMode.Throw);
+
+ Assert.ThrowsException(
+ () => ApplyFirstMatch(settings, $"@env('{ENV_VAR}')"));
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(ENV_VAR, original);
+ }
+ }
+
+ [TestMethod]
+ public void EnvReplacement_MissingVariable_IgnoreMode_ReturnsOriginal()
+ {
+ string? original = Environment.GetEnvironmentVariable(ENV_VAR);
+ try
+ {
+ Environment.SetEnvironmentVariable(ENV_VAR, null);
+ DeserializationVariableReplacementSettings settings = new(
+ doReplaceEnvVar: true,
+ envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore);
+
+ string result = ApplyFirstMatch(settings, $"@env('{ENV_VAR}')");
+
+ Assert.AreEqual($"@env('{ENV_VAR}')", result);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(ENV_VAR, original);
+ }
+ }
+
+ [TestMethod]
+ public void AkvFileReplacement_ResolvesSecretsFromFile()
+ {
+ string path = WriteAkvFile(
+ "# a comment",
+ "secret-name=secretvalue",
+ "quoted=\"quotedvalue\"",
+ "malformed-line-without-equals",
+ "=leadingequals",
+ "dup=first",
+ "dup=second");
+ try
+ {
+ DeserializationVariableReplacementSettings settings = new(
+ azureKeyVaultOptions: new AzureKeyVaultOptions(endpoint: path),
+ doReplaceAkvVar: true);
+
+ Assert.AreEqual(1, settings.ReplacementStrategies.Count);
+ Assert.AreEqual("secretvalue", ApplyFirstMatch(settings, "@akv('secret-name')"));
+ Assert.AreEqual("quotedvalue", ApplyFirstMatch(settings, "@akv('quoted')"));
+ // Duplicate keys keep the first value.
+ Assert.AreEqual("first", ApplyFirstMatch(settings, "@akv('dup')"));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [TestMethod]
+ public void AkvFileReplacement_InvalidSecretName_Throws()
+ {
+ string path = WriteAkvFile("valid=ok");
+ try
+ {
+ DeserializationVariableReplacementSettings settings = new(
+ azureKeyVaultOptions: new AzureKeyVaultOptions(endpoint: path),
+ doReplaceAkvVar: true);
+
+ Assert.ThrowsException(
+ () => ApplyFirstMatch(settings, "@akv('bad name')"));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [TestMethod]
+ public void AkvFileReplacement_MissingSecret_IgnoreMode_ReturnsOriginal()
+ {
+ string path = WriteAkvFile("present=value");
+ try
+ {
+ DeserializationVariableReplacementSettings settings = new(
+ azureKeyVaultOptions: new AzureKeyVaultOptions(endpoint: path),
+ doReplaceAkvVar: true,
+ envFailureMode: EnvironmentVariableReplacementFailureMode.Ignore);
+
+ Assert.AreEqual("@akv('absent')", ApplyFirstMatch(settings, "@akv('absent')"));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [DataTestMethod]
+ [DataRow(AKVRetryPolicyMode.Fixed)]
+ [DataRow(AKVRetryPolicyMode.Exponential)]
+ public void Constructor_RemoteAkvEndpointWithRetryPolicy_RegistersStrategy(AKVRetryPolicyMode mode)
+ {
+ AzureKeyVaultOptions options = new(
+ endpoint: "https://fake-vault.vault.azure.net/",
+ retryPolicy: new AKVRetryPolicyOptions(mode: mode, maxCount: 2, delaySeconds: 1, maxDelaySeconds: 5, networkTimeoutSeconds: 10));
+
+ DeserializationVariableReplacementSettings settings = new(
+ azureKeyVaultOptions: options,
+ doReplaceAkvVar: true);
+
+ Assert.AreEqual(1, settings.ReplacementStrategies.Count);
+ }
+
+ [TestMethod]
+ public void Constructor_RemoteAkvEndpointNoRetryPolicy_RegistersStrategy()
+ {
+ AzureKeyVaultOptions options = new(endpoint: "https://fake-vault.vault.azure.net/");
+
+ DeserializationVariableReplacementSettings settings = new(
+ azureKeyVaultOptions: options,
+ doReplaceAkvVar: true);
+
+ Assert.AreEqual(1, settings.ReplacementStrategies.Count);
+ }
+
+ private static string WriteAkvFile(params string[] lines)
+ {
+ string path = Path.Combine(Path.GetTempPath(), $"dab-test-{Guid.NewGuid():N}.akv");
+ File.WriteAllLines(path, lines);
+ return path;
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/DmlToolsConfigConverterTests.cs b/src/Service.Tests/UnitTests/DmlToolsConfigConverterTests.cs
new file mode 100644
index 0000000000..75020fe951
--- /dev/null
+++ b/src/Service.Tests/UnitTests/DmlToolsConfigConverterTests.cs
@@ -0,0 +1,243 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#nullable disable
+
+using System.IO;
+using System.Text;
+using System.Text.Json;
+using Azure.DataApiBuilder.Config;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json.Linq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for DmlToolsConfigConverter covering the boolean and object JSON
+ /// formats supported for MCP DML tool configuration.
+ ///
+ [TestClass]
+ public class DmlToolsConfigConverterTests
+ {
+ private static JsonSerializerOptions GetOptions()
+ {
+ return RuntimeConfigLoader.GetSerializationOptions();
+ }
+
+ ///
+ /// Serializes a DmlToolsConfig the same way the parent McpRuntimeOptions converter does:
+ /// within a containing JSON object (the converter writes a "dml-tools" property).
+ ///
+ private static string SerializeWithinObject(DmlToolsConfig config)
+ {
+ using MemoryStream ms = new();
+ using (Utf8JsonWriter writer = new(ms))
+ {
+ writer.WriteStartObject();
+ JsonSerializer.Serialize(writer, config, GetOptions());
+ writer.WriteEndObject();
+ }
+
+ return Encoding.UTF8.GetString(ms.ToArray());
+ }
+
+ [TestMethod]
+ public void Deserialize_BooleanTrue_EnablesAllTools()
+ {
+ DmlToolsConfig config = JsonSerializer.Deserialize("true", GetOptions());
+
+ Assert.IsNotNull(config);
+ Assert.IsTrue(config.AllToolsEnabled);
+ Assert.IsTrue(config.CreateRecord);
+ Assert.IsTrue(config.ReadRecords);
+ Assert.IsTrue(config.UpdateRecord);
+ Assert.IsTrue(config.DeleteRecord);
+ Assert.IsTrue(config.ExecuteEntity);
+ Assert.IsTrue(config.DescribeEntities);
+ Assert.IsTrue(config.AggregateRecords);
+ }
+
+ [TestMethod]
+ public void Deserialize_BooleanFalse_DisablesAllTools()
+ {
+ DmlToolsConfig config = JsonSerializer.Deserialize("false", GetOptions());
+
+ Assert.IsNotNull(config);
+ Assert.IsFalse(config.AllToolsEnabled);
+ Assert.IsFalse(config.CreateRecord);
+ Assert.IsFalse(config.ReadRecords);
+ Assert.IsFalse(config.UpdateRecord);
+ Assert.IsFalse(config.DeleteRecord);
+ Assert.IsFalse(config.ExecuteEntity);
+ Assert.IsFalse(config.DescribeEntities);
+ Assert.IsFalse(config.AggregateRecords);
+ }
+
+ [TestMethod]
+ public void Deserialize_ObjectWithIndividualSettings_AppliesOverrides()
+ {
+ string json = @"{
+ ""create-record"": false,
+ ""delete-record"": false,
+ ""read-records"": true
+ }";
+
+ DmlToolsConfig config = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(config);
+ Assert.IsFalse(config.CreateRecord);
+ Assert.IsFalse(config.DeleteRecord);
+ Assert.IsTrue(config.ReadRecords);
+ // Unspecified tools default to enabled.
+ Assert.IsTrue(config.UpdateRecord);
+ Assert.IsTrue(config.ExecuteEntity);
+ Assert.IsTrue(config.DescribeEntities);
+ }
+
+ [TestMethod]
+ public void Deserialize_AggregateRecordsBoolean_IsRead()
+ {
+ string json = @"{ ""aggregate-records"": false }";
+
+ DmlToolsConfig config = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(config);
+ Assert.IsFalse(config.AggregateRecords);
+ Assert.IsTrue(config.UserProvidedAggregateRecords);
+ }
+
+ [TestMethod]
+ public void Deserialize_AggregateRecordsObject_ReadsEnabledAndTimeout()
+ {
+ string json = @"{
+ ""aggregate-records"": {
+ ""enabled"": true,
+ ""query-timeout"": 60
+ }
+ }";
+
+ DmlToolsConfig config = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(config);
+ Assert.IsTrue(config.AggregateRecords);
+ Assert.AreEqual(60, config.AggregateRecordsQueryTimeout);
+ Assert.IsTrue(config.UserProvidedAggregateRecordsQueryTimeout);
+ }
+
+ [TestMethod]
+ public void Deserialize_AggregateRecordsObject_NullTimeout_IsIgnored()
+ {
+ string json = @"{
+ ""aggregate-records"": {
+ ""enabled"": false,
+ ""query-timeout"": null
+ }
+ }";
+
+ DmlToolsConfig config = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(config);
+ Assert.IsFalse(config.AggregateRecords);
+ Assert.IsNull(config.AggregateRecordsQueryTimeout);
+ }
+
+ [TestMethod]
+ public void Deserialize_AggregateRecordsObject_UnknownSubProperty_IsSkipped()
+ {
+ string json = @"{
+ ""aggregate-records"": {
+ ""enabled"": true,
+ ""bogus"": 123
+ }
+ }";
+
+ DmlToolsConfig config = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(config);
+ Assert.IsTrue(config.AggregateRecords);
+ }
+
+ [TestMethod]
+ public void Deserialize_UnknownProperty_IsSkipped()
+ {
+ string json = @"{ ""unknown-tool"": true, ""create-record"": false }";
+
+ DmlToolsConfig config = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(config);
+ Assert.IsFalse(config.CreateRecord);
+ }
+
+ [TestMethod]
+ public void Deserialize_NonBooleanForKnownProperty_ThrowsJsonException()
+ {
+ string json = @"{ ""create-record"": ""yes"" }";
+
+ Assert.ThrowsException(
+ () => JsonSerializer.Deserialize(json, GetOptions()));
+ }
+
+ [TestMethod]
+ public void Deserialize_AggregateRecordsInvalidType_ThrowsJsonException()
+ {
+ string json = @"{ ""aggregate-records"": ""bad"" }";
+
+ Assert.ThrowsException(
+ () => JsonSerializer.Deserialize(json, GetOptions()));
+ }
+
+ [TestMethod]
+ public void Serialize_FromBooleanTrue_WritesBooleanForm()
+ {
+ DmlToolsConfig config = DmlToolsConfig.FromBoolean(true);
+
+ string json = SerializeWithinObject(config);
+ JObject jObject = JObject.Parse(json);
+
+ Assert.IsTrue(jObject.ContainsKey("dml-tools"));
+ Assert.AreEqual(JTokenType.Boolean, jObject["dml-tools"].Type);
+ Assert.IsTrue(jObject["dml-tools"].Value());
+ }
+
+ [TestMethod]
+ public void Serialize_IndividualSettings_WritesObjectForm()
+ {
+ DmlToolsConfig config = new(createRecord: false, readRecords: true);
+
+ string json = SerializeWithinObject(config);
+ JObject jObject = JObject.Parse(json);
+
+ JObject dmlTools = jObject["dml-tools"].Value();
+ Assert.IsNotNull(dmlTools);
+ Assert.IsFalse(dmlTools["create-record"].Value());
+ Assert.IsTrue(dmlTools["read-records"].Value());
+ Assert.IsFalse(dmlTools.ContainsKey("update-record"), "Only user-provided tools should be written.");
+ }
+
+ [TestMethod]
+ public void Serialize_AggregateRecordsWithTimeout_WritesObjectForm()
+ {
+ DmlToolsConfig config = new(aggregateRecords: true, aggregateRecordsQueryTimeout: 45);
+
+ string json = SerializeWithinObject(config);
+ JObject jObject = JObject.Parse(json);
+
+ JObject aggregate = jObject["dml-tools"]["aggregate-records"].Value();
+ Assert.IsNotNull(aggregate);
+ Assert.IsTrue(aggregate["enabled"].Value());
+ Assert.AreEqual(45, aggregate["query-timeout"].Value());
+ }
+
+ [TestMethod]
+ public void Serialize_DefaultConfig_WritesNothing()
+ {
+ DmlToolsConfig config = DmlToolsConfig.Default;
+
+ string json = SerializeWithinObject(config);
+ JObject jObject = JObject.Parse(json);
+
+ Assert.IsFalse(jObject.ContainsKey("dml-tools"), "Default config should not be written.");
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/EdmModelBuilderTests.cs b/src/Service.Tests/UnitTests/EdmModelBuilderTests.cs
new file mode 100644
index 0000000000..c27c754b75
--- /dev/null
+++ b/src/Service.Tests/UnitTests/EdmModelBuilderTests.cs
@@ -0,0 +1,130 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using System.Linq;
+using Azure.DataApiBuilder.Config.DatabasePrimitives;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Parsers;
+using Azure.DataApiBuilder.Core.Services;
+using Microsoft.OData.Edm;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for which builds an OData EDM model from
+ /// in-memory (mocked) database metadata (no live database).
+ ///
+ [TestClass]
+ public class EdmModelBuilderTests
+ {
+ [TestMethod]
+ public void BuildModel_TableEntity_CreatesEntityTypeWithKeyAndProperties()
+ {
+ Dictionary entities = new()
+ {
+ ["Book"] = new DatabaseTable("dbo", "books") { SourceType = EntitySourceType.Table, TableDefinition = BuildSourceDefinition() }
+ };
+
+ IEdmModel model = new EdmModelBuilder()
+ .BuildModel(BuildProvider(entities).Object)
+ .GetModel();
+
+ IEdmEntityType? entityType = model.SchemaElements.OfType()
+ .FirstOrDefault(e => e.Name == "Book.dbo.books");
+
+ Assert.IsNotNull(entityType, "Expected an EDM entity type for the Book table.");
+ Assert.AreEqual(3, entityType!.DeclaredProperties.Count());
+ Assert.AreEqual(1, entityType.DeclaredKey!.Count());
+ Assert.AreEqual("id", entityType.DeclaredKey!.First().Name);
+ }
+
+ [TestMethod]
+ public void BuildModel_TableEntity_AddsEntitySetToContainer()
+ {
+ Dictionary entities = new()
+ {
+ ["Book"] = new DatabaseTable("dbo", "books") { SourceType = EntitySourceType.Table, TableDefinition = BuildSourceDefinition() }
+ };
+
+ IEdmModel model = new EdmModelBuilder()
+ .BuildModel(BuildProvider(entities).Object)
+ .GetModel();
+
+ Assert.IsNotNull(model.EntityContainer);
+ Assert.IsTrue(model.EntityContainer.EntitySets().Any(s => s.Name == "Book.dbo.books"));
+ }
+
+ [TestMethod]
+ public void BuildModel_StoredProcedureEntity_IsSkipped()
+ {
+ Dictionary entities = new()
+ {
+ ["GetBook"] = new DatabaseStoredProcedure("dbo", "get_book")
+ {
+ SourceType = EntitySourceType.StoredProcedure,
+ StoredProcedureDefinition = new StoredProcedureDefinition()
+ }
+ };
+
+ IEdmModel model = new EdmModelBuilder()
+ .BuildModel(BuildProvider(entities).Object)
+ .GetModel();
+
+ Assert.IsFalse(model.SchemaElements.OfType().Any(),
+ "Stored procedures should not be added to the EDM model.");
+ }
+
+ [TestMethod]
+ public void BuildModel_LinkingEntity_IsSkipped()
+ {
+ Dictionary entities = new()
+ {
+ ["BookAuthor"] = new DatabaseTable("dbo", "book_author") { SourceType = EntitySourceType.Table, TableDefinition = BuildSourceDefinition() }
+ };
+ Dictionary linking = new() { ["BookAuthor"] = null! };
+
+ IEdmModel model = new EdmModelBuilder()
+ .BuildModel(BuildProvider(entities, linking).Object)
+ .GetModel();
+
+ Assert.IsFalse(model.SchemaElements.OfType().Any(),
+ "Linking entities should not be added to the EDM model.");
+ }
+
+ #region Helpers
+
+ private static SourceDefinition BuildSourceDefinition()
+ {
+ SourceDefinition sourceDefinition = new() { PrimaryKey = new List { "id" } };
+ sourceDefinition.Columns.Add("id", new ColumnDefinition { SystemType = typeof(int) });
+ sourceDefinition.Columns.Add("title", new ColumnDefinition { SystemType = typeof(string) });
+ sourceDefinition.Columns.Add("publisher_id", new ColumnDefinition { SystemType = typeof(int) });
+ return sourceDefinition;
+ }
+
+ private static Mock BuildProvider(
+ Dictionary entities,
+ Dictionary? linkingEntities = null)
+ {
+ SourceDefinition sourceDefinition = BuildSourceDefinition();
+
+ Mock provider = new();
+ provider.Setup(x => x.GetLinkingEntities()).Returns(linkingEntities ?? new Dictionary());
+ provider.Setup(x => x.GetEntityNamesAndDbObjects()).Returns(entities);
+ provider.Setup(x => x.GetSourceDefinition(It.IsAny())).Returns(sourceDefinition);
+
+ foreach (string column in sourceDefinition.Columns.Keys)
+ {
+ string exposed = column;
+ provider.Setup(x => x.TryGetExposedColumnName(It.IsAny(), column, out exposed)).Returns(true);
+ }
+
+ return provider;
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Service.Tests/UnitTests/EmbeddingsEndpointOptionsTests.cs b/src/Service.Tests/UnitTests/EmbeddingsEndpointOptionsTests.cs
new file mode 100644
index 0000000000..87660f0ab7
--- /dev/null
+++ b/src/Service.Tests/UnitTests/EmbeddingsEndpointOptionsTests.cs
@@ -0,0 +1,71 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.DataApiBuilder.Config.ObjectModel.Embeddings;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for role-resolution logic
+ /// (GetEffectiveRoles / IsRoleAllowed). Pure logic; no database required.
+ ///
+ [TestClass]
+ public class EmbeddingsEndpointOptionsTests
+ {
+ [TestMethod]
+ public void GetEffectiveRoles_ExplicitRoles_AreReturned()
+ {
+ EmbeddingsEndpointOptions options = new(enabled: true, roles: new[] { "reader", "writer" });
+
+ CollectionAssert.AreEqual(new[] { "reader", "writer" }, options.GetEffectiveRoles(isDevelopmentMode: false));
+ CollectionAssert.AreEqual(new[] { "reader", "writer" }, options.GetEffectiveRoles(isDevelopmentMode: true));
+ }
+
+ [TestMethod]
+ public void GetEffectiveRoles_NoRoles_DevelopmentMode_ReturnsAnonymous()
+ {
+ EmbeddingsEndpointOptions options = new(enabled: true);
+
+ CollectionAssert.AreEqual(EmbeddingsEndpointOptions.DEFAULT_ROLES_DEVELOPMENT, options.GetEffectiveRoles(isDevelopmentMode: true));
+ }
+
+ [TestMethod]
+ public void GetEffectiveRoles_NoRoles_ProductionMode_ReturnsAuthenticated()
+ {
+ EmbeddingsEndpointOptions options = new(enabled: true);
+
+ CollectionAssert.AreEqual(EmbeddingsEndpointOptions.DEFAULT_ROLES, options.GetEffectiveRoles(isDevelopmentMode: false));
+ }
+
+ [TestMethod]
+ public void GetEffectiveRoles_EmptyRolesArray_FallsBackToDefaults()
+ {
+ EmbeddingsEndpointOptions options = new(enabled: true, roles: new string[0]);
+
+ CollectionAssert.AreEqual(EmbeddingsEndpointOptions.DEFAULT_ROLES, options.GetEffectiveRoles(isDevelopmentMode: false));
+ }
+
+ [DataTestMethod]
+ [DataRow("authenticated", false, true, DisplayName = "authenticated allowed in production")]
+ [DataRow("AUTHENTICATED", false, true, DisplayName = "role check is case-insensitive")]
+ [DataRow("anonymous", true, true, DisplayName = "anonymous allowed in development")]
+ [DataRow("admin", false, false, DisplayName = "unknown role denied in production")]
+ public void IsRoleAllowed_DefaultRoles_MatchesEnvironment(string role, bool isDevelopment, bool expected)
+ {
+ EmbeddingsEndpointOptions options = new(enabled: true);
+
+ Assert.AreEqual(expected, options.IsRoleAllowed(role, isDevelopment));
+ }
+
+ [TestMethod]
+ public void IsRoleAllowed_ExplicitRoles_ChecksConfiguredList()
+ {
+ EmbeddingsEndpointOptions options = new(enabled: true, roles: new[] { "reader" });
+
+ Assert.IsTrue(options.IsRoleAllowed("reader", isDevelopmentMode: false));
+ Assert.IsTrue(options.IsRoleAllowed("READER", isDevelopmentMode: false));
+ Assert.IsFalse(options.IsRoleAllowed("authenticated", isDevelopmentMode: false));
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/EmbeddingsOptionsConverterTests.cs b/src/Service.Tests/UnitTests/EmbeddingsOptionsConverterTests.cs
new file mode 100644
index 0000000000..4e99a7dfb1
--- /dev/null
+++ b/src/Service.Tests/UnitTests/EmbeddingsOptionsConverterTests.cs
@@ -0,0 +1,209 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#nullable disable
+
+using System.Text.Json;
+using Azure.DataApiBuilder.Config;
+using Azure.DataApiBuilder.Config.ObjectModel.Embeddings;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json.Linq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for EmbeddingsOptionsConverterFactory covering JSON read/write
+ /// of embedding service options, including nested endpoint, health, chunking and cache.
+ ///
+ [TestClass]
+ public class EmbeddingsOptionsConverterTests
+ {
+ private static JsonSerializerOptions GetOptions()
+ {
+ return RuntimeConfigLoader.GetSerializationOptions();
+ }
+
+ [TestMethod]
+ public void Deserialize_AllProperties_AreReadCorrectly()
+ {
+ string json = @"{
+ ""enabled"": true,
+ ""provider"": ""azure-openai"",
+ ""base-url"": ""https://contoso.openai.azure.com"",
+ ""api-key"": ""secret-key"",
+ ""model"": ""text-embedding-3-small"",
+ ""api-version"": ""2023-05-15"",
+ ""dimensions"": 1536,
+ ""timeout-ms"": 5000,
+ ""endpoint"": {
+ ""enabled"": true,
+ ""roles"": [ ""authenticated"" ]
+ },
+ ""health"": {
+ ""enabled"": true,
+ ""threshold-ms"": 1000,
+ ""test-text"": ""hello"",
+ ""expected-dimensions"": 1536
+ },
+ ""chunking"": {
+ ""enabled"": true,
+ ""size-chars"": 500,
+ ""overlap-chars"": 50
+ },
+ ""cache"": {
+ ""enabled"": true,
+ ""ttl-hours"": 24
+ }
+ }";
+
+ EmbeddingsOptions options = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(options);
+ Assert.IsTrue(options.Enabled);
+ Assert.AreEqual(EmbeddingProviderType.AzureOpenAI, options.Provider);
+ Assert.AreEqual("https://contoso.openai.azure.com", options.BaseUrl);
+ Assert.AreEqual("secret-key", options.ApiKey);
+ Assert.AreEqual("text-embedding-3-small", options.Model);
+ Assert.AreEqual("2023-05-15", options.ApiVersion);
+ Assert.AreEqual(1536, options.Dimensions);
+ Assert.AreEqual(5000, options.TimeoutMs);
+ Assert.IsNotNull(options.Endpoint);
+ Assert.IsTrue(options.Endpoint.Enabled);
+ CollectionAssert.AreEqual(new[] { "authenticated" }, options.Endpoint.Roles);
+ Assert.IsNotNull(options.Health);
+ Assert.IsTrue(options.Health.Enabled);
+ Assert.IsNotNull(options.Chunking);
+ Assert.IsTrue(options.Chunking.Enabled);
+ Assert.IsNotNull(options.Cache);
+ Assert.IsTrue(options.Cache.Enabled ?? false);
+ }
+
+ [TestMethod]
+ public void Deserialize_MinimalRequiredProperties_UsesDefaults()
+ {
+ string json = @"{
+ ""provider"": ""openai"",
+ ""base-url"": ""https://api.openai.com"",
+ ""api-key"": ""secret-key""
+ }";
+
+ EmbeddingsOptions options = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(options);
+ Assert.AreEqual(EmbeddingProviderType.OpenAI, options.Provider);
+ Assert.IsTrue(options.Enabled, "Enabled defaults to true when not provided.");
+ Assert.IsNull(options.Model);
+ Assert.IsNull(options.ApiVersion);
+ Assert.AreEqual(EmbeddingsOptions.DEFAULT_DIMENSIONS, options.Dimensions, "Dimensions defaults to DEFAULT_DIMENSIONS when not provided.");
+ Assert.IsNull(options.TimeoutMs);
+ Assert.IsNull(options.Endpoint);
+ Assert.IsNull(options.Health);
+ Assert.IsNull(options.Chunking);
+ Assert.IsNull(options.Cache);
+ }
+
+ [TestMethod]
+ public void Deserialize_UnknownProperty_IsSkipped()
+ {
+ string json = @"{
+ ""provider"": ""openai"",
+ ""base-url"": ""https://api.openai.com"",
+ ""api-key"": ""secret-key"",
+ ""unknown-property"": { ""nested"": [ 1, 2, 3 ] }
+ }";
+
+ EmbeddingsOptions options = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(options);
+ Assert.AreEqual(EmbeddingProviderType.OpenAI, options.Provider);
+ }
+
+ [TestMethod]
+ public void Deserialize_UnknownProvider_ThrowsJsonException()
+ {
+ string json = @"{
+ ""provider"": ""unsupported-provider"",
+ ""base-url"": ""https://api.openai.com"",
+ ""api-key"": ""secret-key""
+ }";
+
+ Assert.ThrowsException(
+ () => JsonSerializer.Deserialize(json, GetOptions()));
+ }
+
+ [DataTestMethod]
+ [DataRow(@"{ ""base-url"": ""https://api.openai.com"", ""api-key"": ""k"" }", DisplayName = "Missing provider")]
+ [DataRow(@"{ ""provider"": ""openai"", ""api-key"": ""k"" }", DisplayName = "Missing base-url")]
+ [DataRow(@"{ ""provider"": ""openai"", ""base-url"": ""https://api.openai.com"" }", DisplayName = "Missing api-key")]
+ public void Deserialize_MissingRequiredProperty_ThrowsJsonException(string json)
+ {
+ Assert.ThrowsException(
+ () => JsonSerializer.Deserialize(json, GetOptions()));
+ }
+
+ [TestMethod]
+ public void Deserialize_NonObjectToken_ThrowsJsonException()
+ {
+ Assert.ThrowsException(
+ () => JsonSerializer.Deserialize("\"not-an-object\"", GetOptions()));
+ }
+
+ [TestMethod]
+ public void Serialize_MinimalOptions_OmitsUnsetOptionalProperties()
+ {
+ string json = @"{
+ ""provider"": ""openai"",
+ ""base-url"": ""https://api.openai.com"",
+ ""api-key"": ""secret-key""
+ }";
+
+ EmbeddingsOptions options = JsonSerializer.Deserialize(json, GetOptions());
+ string serialized = JsonSerializer.Serialize(options, GetOptions());
+ JObject jObject = JObject.Parse(serialized);
+
+ Assert.AreEqual("openai", jObject["provider"].Value());
+ Assert.AreEqual("https://api.openai.com", jObject["base-url"].Value());
+ Assert.AreEqual("secret-key", jObject["api-key"].Value());
+ Assert.IsFalse(jObject.ContainsKey("model"));
+ Assert.IsFalse(jObject.ContainsKey("endpoint"));
+ Assert.IsFalse(jObject.ContainsKey("health"));
+ Assert.IsFalse(jObject.ContainsKey("chunking"));
+ Assert.IsFalse(jObject.ContainsKey("cache"));
+ }
+
+ [TestMethod]
+ public void RoundTrip_PreservesScalarAndNestedValues()
+ {
+ string json = @"{
+ ""enabled"": false,
+ ""provider"": ""azure-openai"",
+ ""base-url"": ""https://contoso.openai.azure.com"",
+ ""api-key"": ""secret-key"",
+ ""model"": ""my-deployment"",
+ ""api-version"": ""2023-05-15"",
+ ""dimensions"": 768,
+ ""timeout-ms"": 12000,
+ ""endpoint"": {
+ ""enabled"": true,
+ ""roles"": [ ""authenticated"", ""reader"" ]
+ }
+ }";
+
+ EmbeddingsOptions original = JsonSerializer.Deserialize(json, GetOptions());
+ string serialized = JsonSerializer.Serialize(original, GetOptions());
+ EmbeddingsOptions result = JsonSerializer.Deserialize(serialized, GetOptions());
+
+ Assert.AreEqual(original.Enabled, result.Enabled);
+ Assert.AreEqual(original.Provider, result.Provider);
+ Assert.AreEqual(original.BaseUrl, result.BaseUrl);
+ Assert.AreEqual(original.ApiKey, result.ApiKey);
+ Assert.AreEqual(original.Model, result.Model);
+ Assert.AreEqual(original.ApiVersion, result.ApiVersion);
+ Assert.AreEqual(original.Dimensions, result.Dimensions);
+ Assert.AreEqual(original.TimeoutMs, result.TimeoutMs);
+ Assert.IsNotNull(result.Endpoint);
+ Assert.AreEqual(original.Endpoint.Enabled, result.Endpoint.Enabled);
+ CollectionAssert.AreEqual(original.Endpoint.Roles, result.Endpoint.Roles);
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/EnumExtensionsTests.cs b/src/Service.Tests/UnitTests/EnumExtensionsTests.cs
new file mode 100644
index 0000000000..63f8534268
--- /dev/null
+++ b/src/Service.Tests/UnitTests/EnumExtensionsTests.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json;
+using Azure.DataApiBuilder.Config.Converters;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for string-to-enum conversion helpers,
+ /// covering both plain enum names and EnumMember-attributed values.
+ /// Pure logic; no database required.
+ ///
+ [TestClass]
+ public class EnumExtensionsTests
+ {
+ [TestMethod]
+ public void Deserialize_EnumMemberValue_IsResolved()
+ {
+ Assert.AreEqual(EntitySourceType.StoredProcedure, EnumExtensions.Deserialize("stored-procedure"));
+ Assert.AreEqual(EntitySourceType.Table, EnumExtensions.Deserialize("table"));
+ }
+
+ [DataTestMethod]
+ [DataRow("Fixed", AKVRetryPolicyMode.Fixed, DisplayName = "Exact enum name")]
+ [DataRow("exponential", AKVRetryPolicyMode.Exponential, DisplayName = "Lower-case enum name")]
+ [DataRow("EXPONENTIAL", AKVRetryPolicyMode.Exponential, DisplayName = "Upper-case enum name")]
+ public void Deserialize_EnumName_IsCaseInsensitive(string value, AKVRetryPolicyMode expected)
+ {
+ Assert.AreEqual(expected, EnumExtensions.Deserialize(value));
+ }
+
+ [TestMethod]
+ public void Deserialize_InvalidValue_ThrowsJsonException()
+ {
+ Assert.ThrowsException(
+ () => EnumExtensions.Deserialize("not-a-mode"));
+ }
+
+ [TestMethod]
+ public void TryDeserialize_ValidValue_ReturnsTrue()
+ {
+ bool ok = EnumExtensions.TryDeserialize("Fixed", out AKVRetryPolicyMode? mode);
+
+ Assert.IsTrue(ok);
+ Assert.AreEqual(AKVRetryPolicyMode.Fixed, mode);
+ }
+
+ [TestMethod]
+ public void TryDeserialize_InvalidValue_ReturnsFalse()
+ {
+ bool ok = EnumExtensions.TryDeserialize("bogus", out AKVRetryPolicyMode? mode);
+
+ Assert.IsFalse(ok);
+ Assert.IsNull(mode);
+ }
+
+ [TestMethod]
+ public void GenerateMessageForInvalidInput_ListsValidValues()
+ {
+ string message = EnumExtensions.GenerateMessageForInvalidInput("bogus");
+
+ StringAssert.Contains(message, "bogus");
+ StringAssert.Contains(message, "Fixed");
+ StringAssert.Contains(message, "Exponential");
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/HttpStatusCodeExtensionsTests.cs b/src/Service.Tests/UnitTests/HttpStatusCodeExtensionsTests.cs
new file mode 100644
index 0000000000..7773e92eee
--- /dev/null
+++ b/src/Service.Tests/UnitTests/HttpStatusCodeExtensionsTests.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Net;
+using Azure.DataApiBuilder.Config.Utilities;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for . Pure logic; no database required.
+ ///
+ [TestClass]
+ public class HttpStatusCodeExtensionsTests
+ {
+ [DataTestMethod]
+ [DataRow(HttpStatusCode.BadRequest, true, DisplayName = "400 is a client error")]
+ [DataRow(HttpStatusCode.NotFound, true, DisplayName = "404 is a client error")]
+ [DataRow(HttpStatusCode.Forbidden, true, DisplayName = "403 is a client error")]
+ [DataRow(HttpStatusCode.OK, false, DisplayName = "200 is not a client error")]
+ [DataRow(HttpStatusCode.InternalServerError, false, DisplayName = "500 is not a client error")]
+ [DataRow(HttpStatusCode.MovedPermanently, false, DisplayName = "301 is not a client error")]
+ public void IsClientError_ReturnsExpected(HttpStatusCode statusCode, bool expected)
+ {
+ Assert.AreEqual(expected, statusCode.IsClientError());
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/MergeJsonProviderTests.cs b/src/Service.Tests/UnitTests/MergeJsonProviderTests.cs
new file mode 100644
index 0000000000..1a43cbc245
--- /dev/null
+++ b/src/Service.Tests/UnitTests/MergeJsonProviderTests.cs
@@ -0,0 +1,106 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using Azure.DataApiBuilder.Config;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json.Linq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for which merges two JSON strings.
+ /// Pure logic; no database required.
+ ///
+ [TestClass]
+ public class MergeJsonProviderTests
+ {
+ [TestMethod]
+ public void Merge_OverridesScalarProperty()
+ {
+ string result = MergeJsonProvider.Merge(@"{ ""a"": 1, ""b"": 2 }", @"{ ""b"": 99 }");
+ JObject merged = JObject.Parse(result);
+
+ Assert.AreEqual(1, merged["a"].Value());
+ Assert.AreEqual(99, merged["b"].Value());
+ }
+
+ [TestMethod]
+ public void Merge_RecursivelyMergesNestedObjects()
+ {
+ string original = @"{ ""outer"": { ""x"": 1, ""y"": 2 } }";
+ string overriding = @"{ ""outer"": { ""y"": 20, ""z"": 30 } }";
+
+ JObject merged = JObject.Parse(MergeJsonProvider.Merge(original, overriding));
+
+ Assert.AreEqual(1, merged["outer"]["x"].Value());
+ Assert.AreEqual(20, merged["outer"]["y"].Value());
+ Assert.AreEqual(30, merged["outer"]["z"].Value());
+ }
+
+ [TestMethod]
+ public void Merge_ReplacesArraysRatherThanMerging()
+ {
+ string original = @"{ ""items"": [1, 2, 3] }";
+ string overriding = @"{ ""items"": [9] }";
+
+ JObject merged = JObject.Parse(MergeJsonProvider.Merge(original, overriding));
+
+ JArray items = (JArray)merged["items"];
+ Assert.AreEqual(1, items.Count);
+ Assert.AreEqual(9, items[0].Value());
+ }
+
+ [TestMethod]
+ public void Merge_AddsPropertiesUniqueToSecondDocument()
+ {
+ JObject merged = JObject.Parse(MergeJsonProvider.Merge(@"{ ""a"": 1 }", @"{ ""b"": 2 }"));
+
+ Assert.AreEqual(1, merged["a"].Value());
+ Assert.AreEqual(2, merged["b"].Value());
+ }
+
+ [TestMethod]
+ public void Merge_NullInOverride_KeepsOriginalValue()
+ {
+ JObject merged = JObject.Parse(MergeJsonProvider.Merge(@"{ ""a"": 1 }", @"{ ""a"": null }"));
+
+ Assert.AreEqual(1, merged["a"].Value());
+ }
+
+ [TestMethod]
+ public void Merge_TypeMismatch_OverridesWithSecondValue()
+ {
+ // Original 'a' is an object, override is a string -> override wins.
+ JObject merged = JObject.Parse(MergeJsonProvider.Merge(@"{ ""a"": { ""x"": 1 } }", @"{ ""a"": ""str"" }"));
+
+ Assert.AreEqual("str", merged["a"].Value());
+ }
+
+ [TestMethod]
+ public void Merge_RootTypeMismatch_ReturnsOriginalUnchanged()
+ {
+ string original = @"{ ""a"": 1 }";
+
+ string result = MergeJsonProvider.Merge(original, "[1, 2]");
+
+ Assert.AreEqual(original, result);
+ }
+
+ [TestMethod]
+ public void Merge_RootArrays_OverridesWithSecondArray()
+ {
+ JArray merged = JArray.Parse(MergeJsonProvider.Merge("[1, 2, 3]", "[7, 8]"));
+
+ Assert.AreEqual(2, merged.Count);
+ Assert.AreEqual(7, merged[0].Value());
+ }
+
+ [TestMethod]
+ public void Merge_NonContainerOriginal_ThrowsInvalidOperationException()
+ {
+ Assert.ThrowsException(
+ () => MergeJsonProvider.Merge("42", "43"));
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/OpenApiDocumentorUnitTests.cs b/src/Service.Tests/UnitTests/OpenApiDocumentorUnitTests.cs
new file mode 100644
index 0000000000..d855af2724
--- /dev/null
+++ b/src/Service.Tests/UnitTests/OpenApiDocumentorUnitTests.cs
@@ -0,0 +1,509 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using Azure.DataApiBuilder.Config.DatabasePrimitives;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Configurations;
+using Azure.DataApiBuilder.Core.Services;
+using Azure.DataApiBuilder.Core.Services.MetadataProviders;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for that exercise document generation
+ /// against an in-memory (mocked) metadata provider, avoiding any live database dependency.
+ ///
+ [TestClass]
+ public class OpenApiDocumentorUnitTests
+ {
+ private const string ENTITY_NAME = "Book";
+
+ [TestMethod]
+ public void TryGetDocument_BeforeGeneration_ReturnsFalse()
+ {
+ OpenApiDocumentor documentor = CreateDocumentorWithBookTable();
+
+ Assert.IsFalse(documentor.TryGetDocument(out string? document));
+ Assert.IsNull(document);
+ }
+
+ [TestMethod]
+ public void CreateDocument_RestDisabledGlobally_Throws()
+ {
+ OpenApiDocumentor documentor = CreateDocumentorWithBookTable(restEnabledGlobally: false);
+
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => documentor.CreateDocument());
+
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.GlobalRestEndpointDisabled, ex.SubStatusCode);
+ }
+
+ [TestMethod]
+ public void CreateDocument_TableEntity_GeneratesExpectedPathsAndSchemas()
+ {
+ OpenApiDocumentor documentor = CreateDocumentorWithBookTable();
+
+ documentor.CreateDocument();
+
+ Assert.IsTrue(documentor.TryGetDocument(out string? document));
+ using JsonDocument doc = JsonDocument.Parse(document!);
+ JsonElement root = doc.RootElement;
+
+ JsonElement paths = root.GetProperty("paths");
+ // Path excluding primary key exposes GET(all) and POST.
+ JsonElement basePath = paths.GetProperty("/Book");
+ Assert.IsTrue(basePath.TryGetProperty("get", out _));
+ Assert.IsTrue(basePath.TryGetProperty("post", out _));
+
+ // Path including primary key exposes GET(one), PUT, PATCH, DELETE.
+ JsonElement pkPath = paths.GetProperty("/Book/id/{id}");
+ Assert.IsTrue(pkPath.TryGetProperty("get", out _));
+ Assert.IsTrue(pkPath.TryGetProperty("put", out _));
+ Assert.IsTrue(pkPath.TryGetProperty("patch", out _));
+ Assert.IsTrue(pkPath.TryGetProperty("delete", out _));
+
+ JsonElement schemas = root.GetProperty("components").GetProperty("schemas");
+ Assert.IsTrue(schemas.TryGetProperty("Book", out _));
+ // Request-body schemas generated for mutation operations under strict mode.
+ Assert.IsTrue(schemas.TryGetProperty("Book_NoAutoPK", out _));
+ Assert.IsTrue(schemas.TryGetProperty("Book_NoPK", out _));
+ }
+
+ [TestMethod]
+ public void CreateDocument_CalledTwiceWithoutOverride_Throws()
+ {
+ OpenApiDocumentor documentor = CreateDocumentorWithBookTable();
+ documentor.CreateDocument();
+
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => documentor.CreateDocument());
+
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.OpenApiDocumentAlreadyExists, ex.SubStatusCode);
+ }
+
+ [TestMethod]
+ public void CreateDocument_WithOverride_Regenerates()
+ {
+ OpenApiDocumentor documentor = CreateDocumentorWithBookTable();
+ documentor.CreateDocument();
+
+ // Should not throw when override is requested.
+ documentor.CreateDocument(doOverrideExistingDocument: true);
+
+ Assert.IsTrue(documentor.TryGetDocument(out _));
+ }
+
+ [TestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow(" ")]
+ public void TryGetDocumentForRole_NullOrWhitespace_ReturnsFalse(string role)
+ {
+ OpenApiDocumentor documentor = CreateDocumentorWithBookTable();
+
+ Assert.IsFalse(documentor.TryGetDocumentForRole(role, out string? document));
+ Assert.IsNull(document);
+ }
+
+ [TestMethod]
+ public void TryGetDocumentForRole_UnknownRole_ReturnsFalse()
+ {
+ OpenApiDocumentor documentor = CreateDocumentorWithBookTable();
+
+ Assert.IsFalse(documentor.TryGetDocumentForRole("nonexistent-role", out string? document));
+ Assert.IsNull(document);
+ }
+
+ [TestMethod]
+ public void TryGetDocumentForRole_KnownRole_ReturnsDocument()
+ {
+ OpenApiDocumentor documentor = CreateDocumentorWithBookTable();
+
+ Assert.IsTrue(documentor.TryGetDocumentForRole("anonymous", out string? document));
+ Assert.IsNotNull(document);
+
+ // Second call should hit the role-specific document cache.
+ Assert.IsTrue(documentor.TryGetDocumentForRole("anonymous", out string? cachedDocument));
+ Assert.AreEqual(document, cachedDocument);
+ }
+
+ [TestMethod]
+ public void CreateDocument_EntityWithRestDisabled_ExcludedFromPaths()
+ {
+ Dictionary entities = new()
+ {
+ [ENTITY_NAME] = CreateTableEntity(restEnabled: false)
+ };
+ OpenApiDocumentor documentor = CreateDocumentor(entities, CreateBookMetadata());
+
+ documentor.CreateDocument();
+
+ Assert.IsTrue(documentor.TryGetDocument(out string? document));
+ using JsonDocument doc = JsonDocument.Parse(document!);
+ JsonElement paths = doc.RootElement.GetProperty("paths");
+ Assert.AreEqual(0, paths.EnumerateObject().Count());
+ }
+
+ [TestMethod]
+ public void CreateDocument_ReadOnlyPermission_OnlyExposesGetOperations()
+ {
+ Entity readOnlyEntity = new(
+ Source: new(ENTITY_NAME, EntitySourceType.Table, null, null),
+ GraphQL: new(ENTITY_NAME, ENTITY_NAME + "s"),
+ Fields: null,
+ Rest: new(Enabled: true),
+ Permissions: new[]
+ {
+ new EntityPermission(Role: "anonymous", Actions: new[]
+ {
+ new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null)
+ })
+ },
+ Mappings: null,
+ Relationships: null,
+ Mcp: null);
+
+ Dictionary entities = new() { [ENTITY_NAME] = readOnlyEntity };
+ OpenApiDocumentor documentor = CreateDocumentor(entities, CreateBookMetadata());
+
+ documentor.CreateDocument();
+
+ Assert.IsTrue(documentor.TryGetDocument(out string? document));
+ using JsonDocument doc = JsonDocument.Parse(document!);
+ JsonElement paths = doc.RootElement.GetProperty("paths");
+
+ JsonElement basePath = paths.GetProperty("/Book");
+ Assert.IsTrue(basePath.TryGetProperty("get", out _));
+ Assert.IsFalse(basePath.TryGetProperty("post", out _));
+
+ JsonElement pkPath = paths.GetProperty("/Book/id/{id}");
+ Assert.IsTrue(pkPath.TryGetProperty("get", out _));
+ Assert.IsFalse(pkPath.TryGetProperty("put", out _));
+ Assert.IsFalse(pkPath.TryGetProperty("delete", out _));
+ }
+
+ [TestMethod]
+ public void CreateDocument_StoredProcedureEntity_GeneratesExecutePathAndSchemas()
+ {
+ Entity spEntity = new(
+ Source: new("get_book", EntitySourceType.StoredProcedure, null, null),
+ GraphQL: new("GetBook", "GetBook"),
+ Fields: null,
+ Rest: new(Enabled: true),
+ Permissions: new[]
+ {
+ new EntityPermission(Role: "anonymous", Actions: new[]
+ {
+ new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null)
+ })
+ },
+ Mappings: null,
+ Relationships: null,
+ Mcp: null);
+
+ StoredProcedureDefinition spDefinition = new()
+ {
+ Parameters = new Dictionary
+ {
+ ["id"] = new ParameterDefinition { SystemType = typeof(int), Required = true }
+ }
+ };
+ spDefinition.Columns.Add("id", new ColumnDefinition { SystemType = typeof(int) });
+ spDefinition.Columns.Add("title", new ColumnDefinition { SystemType = typeof(string) });
+
+ DatabaseStoredProcedure dbSp = new(schemaName: "dbo", tableName: "get_book")
+ {
+ SourceType = EntitySourceType.StoredProcedure,
+ StoredProcedureDefinition = spDefinition
+ };
+
+ Dictionary dbObjects = new() { ["GetBook"] = dbSp };
+ Dictionary entities = new() { ["GetBook"] = spEntity };
+ OpenApiDocumentor documentor = CreateDocumentor(entities, dbObjects, new[] { "id", "title" });
+
+ documentor.CreateDocument();
+
+ Assert.IsTrue(documentor.TryGetDocument(out string? document));
+ using JsonDocument doc = JsonDocument.Parse(document!);
+ JsonElement root = doc.RootElement;
+
+ // Default SP REST method is POST.
+ JsonElement spPath = root.GetProperty("paths").GetProperty("/GetBook");
+ Assert.IsTrue(spPath.TryGetProperty("post", out _));
+
+ JsonElement schemas = root.GetProperty("components").GetProperty("schemas");
+ Assert.IsTrue(schemas.TryGetProperty("GetBook_sp_request", out _));
+ Assert.IsTrue(schemas.TryGetProperty("GetBook_sp_response", out _));
+ }
+
+ [TestMethod]
+ public void CreateDocument_ViewEntity_GeneratesPaths()
+ {
+ ViewDefinition sourceDefinition = new() { PrimaryKey = new List { "id" } };
+ sourceDefinition.Columns.Add("id", new ColumnDefinition { SystemType = typeof(int), IsAutoGenerated = true });
+ sourceDefinition.Columns.Add("title", new ColumnDefinition { SystemType = typeof(string) });
+
+ DatabaseView dbView = new(schemaName: "dbo", tableName: "book_view")
+ {
+ SourceType = EntitySourceType.View,
+ ViewDefinition = sourceDefinition
+ };
+
+ Entity viewEntity = new(
+ Source: new("book_view", EntitySourceType.View, null, null),
+ GraphQL: new("BookView", "BookViews"),
+ Fields: null,
+ Rest: new(Enabled: true),
+ Permissions: new[]
+ {
+ new EntityPermission(Role: "anonymous", Actions: new[]
+ {
+ new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null)
+ })
+ },
+ Mappings: null,
+ Relationships: null,
+ Mcp: null);
+
+ Dictionary dbObjects = new() { ["BookView"] = dbView };
+ Dictionary entities = new() { ["BookView"] = viewEntity };
+ OpenApiDocumentor documentor = CreateDocumentor(entities, dbObjects, new[] { "id", "title" });
+
+ documentor.CreateDocument();
+
+ Assert.IsTrue(documentor.TryGetDocument(out string? document));
+ using JsonDocument doc = JsonDocument.Parse(document!);
+ JsonElement paths = doc.RootElement.GetProperty("paths");
+ // Read-only view exposes GET operations only.
+ Assert.IsTrue(paths.GetProperty("/BookView").TryGetProperty("get", out _));
+ Assert.IsFalse(paths.GetProperty("/BookView").TryGetProperty("post", out _));
+ }
+
+ [TestMethod]
+ public void CreateDocument_MultipleEntities_GeneratesPathsForEach()
+ {
+ Dictionary dbObjects = CreateBookMetadata();
+ dbObjects["Author"] = new DatabaseTable(schemaName: "dbo", tableName: "authors")
+ {
+ SourceType = EntitySourceType.Table,
+ TableDefinition = CreateSimpleSourceDefinition()
+ };
+
+ Dictionary entities = new()
+ {
+ [ENTITY_NAME] = CreateTableEntity(restEnabled: true),
+ ["Author"] = new Entity(
+ Source: new("Author", EntitySourceType.Table, null, null),
+ GraphQL: new("Author", "Authors"),
+ Fields: null,
+ Rest: new(Enabled: true),
+ Permissions: new[]
+ {
+ new EntityPermission(Role: "anonymous", Actions: new[]
+ {
+ new EntityAction(Action: EntityActionOperation.All, Fields: null, Policy: null)
+ })
+ },
+ Mappings: null,
+ Relationships: null,
+ Mcp: null)
+ };
+
+ OpenApiDocumentor documentor = CreateDocumentor(entities, dbObjects);
+ documentor.CreateDocument();
+
+ Assert.IsTrue(documentor.TryGetDocument(out string? document));
+ using JsonDocument doc = JsonDocument.Parse(document!);
+ JsonElement paths = doc.RootElement.GetProperty("paths");
+ Assert.IsTrue(paths.TryGetProperty("/Book", out _));
+ Assert.IsTrue(paths.TryGetProperty("/Author", out _));
+ }
+
+ [TestMethod]
+ public void CreateDocument_CustomRestPath_UsesConfiguredPath()
+ {
+ Entity entity = new(
+ Source: new(ENTITY_NAME, EntitySourceType.Table, null, null),
+ GraphQL: new(ENTITY_NAME, ENTITY_NAME + "s"),
+ Fields: null,
+ Rest: new(Enabled: true) { Path = "/customBooks" },
+ Permissions: new[]
+ {
+ new EntityPermission(Role: "anonymous", Actions: new[]
+ {
+ new EntityAction(Action: EntityActionOperation.All, Fields: null, Policy: null)
+ })
+ },
+ Mappings: null,
+ Relationships: null,
+ Mcp: null);
+
+ Dictionary entities = new() { [ENTITY_NAME] = entity };
+ OpenApiDocumentor documentor = CreateDocumentor(entities, CreateBookMetadata());
+ documentor.CreateDocument();
+
+ Assert.IsTrue(documentor.TryGetDocument(out string? document));
+ using JsonDocument doc = JsonDocument.Parse(document!);
+ JsonElement paths = doc.RootElement.GetProperty("paths");
+ Assert.IsTrue(paths.TryGetProperty("/customBooks", out _));
+ Assert.IsFalse(paths.TryGetProperty("/Book", out _));
+ }
+
+ [TestMethod]
+ public void CreateDocument_RequestBodyStrictFalse_OmitsStrictRequestBodySchemas()
+ {
+ Dictionary entities = new() { [ENTITY_NAME] = CreateTableEntity(restEnabled: true) };
+ OpenApiDocumentor documentor = CreateDocumentor(entities, CreateBookMetadata(), requestBodyStrict: false);
+ documentor.CreateDocument();
+
+ Assert.IsTrue(documentor.TryGetDocument(out string? document));
+ using JsonDocument doc = JsonDocument.Parse(document!);
+ JsonElement schemas = doc.RootElement.GetProperty("components").GetProperty("schemas");
+ // Non-strict mode does not generate the separate strict request-body schemas.
+ Assert.IsTrue(schemas.TryGetProperty("Book", out _));
+ Assert.IsFalse(schemas.TryGetProperty("Book_NoAutoPK", out _));
+ Assert.IsFalse(schemas.TryGetProperty("Book_NoPK", out _));
+ }
+
+ [TestMethod]
+ public void CreateDocument_WithBaseRoute_ServerUrlIncludesBaseRoute()
+ {
+ Dictionary entities = new() { [ENTITY_NAME] = CreateTableEntity(restEnabled: true) };
+ OpenApiDocumentor documentor = CreateDocumentor(entities, CreateBookMetadata(), baseRoute: "base");
+ documentor.CreateDocument();
+
+ Assert.IsTrue(documentor.TryGetDocument(out string? document));
+ using JsonDocument doc = JsonDocument.Parse(document!);
+ string serverUrl = doc.RootElement.GetProperty("servers")[0].GetProperty("url").GetString()!;
+ StringAssert.Contains(serverUrl, "base");
+ }
+
+ #region Helpers
+
+ private static SourceDefinition CreateSimpleSourceDefinition()
+ {
+ SourceDefinition sourceDefinition = new() { PrimaryKey = new List { "id" } };
+ sourceDefinition.Columns.Add("id", new ColumnDefinition { SystemType = typeof(int), IsAutoGenerated = true });
+ sourceDefinition.Columns.Add("title", new ColumnDefinition { SystemType = typeof(string) });
+ sourceDefinition.Columns.Add("publisher_id", new ColumnDefinition { SystemType = typeof(int), IsNullable = true });
+ return sourceDefinition;
+ }
+
+ private static OpenApiDocumentor CreateDocumentorWithBookTable(bool restEnabledGlobally = true)
+ {
+ Dictionary entities = new() { [ENTITY_NAME] = CreateTableEntity(restEnabled: true) };
+ return CreateDocumentor(entities, CreateBookMetadata(), restEnabledGlobally: restEnabledGlobally);
+ }
+
+ private static Entity CreateTableEntity(bool restEnabled)
+ {
+ return new Entity(
+ Source: new(ENTITY_NAME, EntitySourceType.Table, null, null),
+ GraphQL: new(ENTITY_NAME, ENTITY_NAME + "s"),
+ Fields: null,
+ Rest: new(Enabled: restEnabled),
+ Permissions: new[]
+ {
+ new EntityPermission(Role: "anonymous", Actions: new[]
+ {
+ new EntityAction(Action: EntityActionOperation.All, Fields: null, Policy: null)
+ })
+ },
+ Mappings: null,
+ Relationships: null,
+ Mcp: null);
+ }
+
+ ///
+ /// Builds a Book table database object with an auto-generated integer primary key
+ /// and a couple of scalar columns.
+ ///
+ private static Dictionary CreateBookMetadata()
+ {
+ SourceDefinition sourceDefinition = new()
+ {
+ PrimaryKey = new List { "id" }
+ };
+ sourceDefinition.Columns.Add("id", new ColumnDefinition { SystemType = typeof(int), IsAutoGenerated = true });
+ sourceDefinition.Columns.Add("title", new ColumnDefinition { SystemType = typeof(string) });
+ sourceDefinition.Columns.Add("publisher_id", new ColumnDefinition { SystemType = typeof(int), IsNullable = true });
+
+ DatabaseTable dbTable = new(schemaName: "dbo", tableName: "books")
+ {
+ SourceType = EntitySourceType.Table,
+ TableDefinition = sourceDefinition
+ };
+
+ return new Dictionary { [ENTITY_NAME] = dbTable };
+ }
+
+ private static OpenApiDocumentor CreateDocumentor(
+ Dictionary entities,
+ Dictionary dbObjects,
+ IEnumerable? columnNames = null,
+ bool restEnabledGlobally = true,
+ bool requestBodyStrict = true,
+ string? baseRoute = null)
+ {
+ Mock mockMetadataProvider = new();
+ mockMetadataProvider.Setup(x => x.EntityToDatabaseObject).Returns(dbObjects);
+ mockMetadataProvider.Setup(x => x.GetDatabaseType()).Returns(DatabaseType.MSSQL);
+
+ foreach (KeyValuePair entry in dbObjects)
+ {
+ DatabaseObject dbObject = entry.Value;
+ mockMetadataProvider.Setup(x => x.GetSourceDefinition(entry.Key)).Returns(dbObject.SourceDefinition);
+ }
+
+ // Column-name identity mappings (no aliasing) required by schema/path generation.
+ IEnumerable columns = columnNames ?? new[] { "id", "title", "publisher_id" };
+ foreach (string column in columns)
+ {
+ string exposedName = column;
+ string backingName = column;
+ mockMetadataProvider
+ .Setup(x => x.TryGetExposedColumnName(It.IsAny(), column, out exposedName))
+ .Returns(true);
+ mockMetadataProvider
+ .Setup(x => x.TryGetBackingColumn(It.IsAny(), column, out backingName))
+ .Returns(true);
+ }
+
+ Mock mockFactory = new();
+ mockFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(mockMetadataProvider.Object);
+
+ RuntimeConfig config = CreateConfig(entities, restEnabledGlobally, requestBodyStrict, baseRoute);
+ RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config);
+
+ return new OpenApiDocumentor(
+ mockFactory.Object,
+ configProvider,
+ handler: null,
+ logger: NullLogger.Instance);
+ }
+
+ private static RuntimeConfig CreateConfig(Dictionary entities, bool restEnabledGlobally, bool requestBodyStrict = true, string? baseRoute = null)
+ {
+ return new RuntimeConfig(
+ Schema: "test-schema",
+ DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: string.Empty, Options: null),
+ Runtime: new(
+ Rest: new(Enabled: restEnabledGlobally, RequestBodyStrict: requestBodyStrict),
+ GraphQL: new(),
+ Mcp: null,
+ Host: new(Cors: null, Authentication: null, Mode: HostMode.Development),
+ BaseRoute: baseRoute),
+ Entities: new(entities));
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Service.Tests/UnitTests/ProductInfoTests.cs b/src/Service.Tests/UnitTests/ProductInfoTests.cs
new file mode 100644
index 0000000000..d75588d5af
--- /dev/null
+++ b/src/Service.Tests/UnitTests/ProductInfoTests.cs
@@ -0,0 +1,69 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using Azure.DataApiBuilder.Product;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for version and user-agent helpers.
+ /// Pure logic; no database required.
+ ///
+ [TestClass]
+ public class ProductInfoTests
+ {
+ [TestMethod]
+ public void GetProductVersion_ReturnsMajorMinorPatch()
+ {
+ string version = ProductInfo.GetProductVersion();
+
+ Assert.IsFalse(string.IsNullOrWhiteSpace(version));
+ Assert.IsTrue(version.Split('.').Length >= 3, $"Expected Major.Minor.Patch, got '{version}'.");
+ }
+
+ [TestMethod]
+ public void GetProductVersion_WithCommitHash_ReturnsNonEmpty()
+ {
+ string version = ProductInfo.GetProductVersion(includeCommitHash: true);
+
+ Assert.IsFalse(string.IsNullOrWhiteSpace(version));
+ }
+
+ [TestMethod]
+ public void GetDataApiBuilderUserAgent_WhenEnvNotSet_ReturnsDefault()
+ {
+ string? original = Environment.GetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV);
+ try
+ {
+ Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, null);
+
+ string userAgent = ProductInfo.GetDataApiBuilderUserAgent();
+
+ Assert.AreEqual(ProductInfo.DAB_USER_AGENT, userAgent);
+ StringAssert.StartsWith(userAgent, "dab_oss_");
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, original);
+ }
+ }
+
+ [TestMethod]
+ public void GetDataApiBuilderUserAgent_WhenEnvSet_ReturnsEnvValue()
+ {
+ string? original = Environment.GetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV);
+ try
+ {
+ Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, "custom-agent");
+
+ Assert.AreEqual("custom-agent", ProductInfo.GetDataApiBuilderUserAgent());
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, original);
+ }
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/RequestValidatorInstanceUnitTests.cs b/src/Service.Tests/UnitTests/RequestValidatorInstanceUnitTests.cs
new file mode 100644
index 0000000000..4948dd6620
--- /dev/null
+++ b/src/Service.Tests/UnitTests/RequestValidatorInstanceUnitTests.cs
@@ -0,0 +1,323 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using System.Net;
+using System.Text.Json;
+using Azure.DataApiBuilder.Config.DatabasePrimitives;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Configurations;
+using Azure.DataApiBuilder.Core.Models;
+using Azure.DataApiBuilder.Core.Services;
+using Azure.DataApiBuilder.Core.Services.MetadataProviders;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for the instance-level validation methods on
+ /// that rely on database metadata (mocked in-memory, no live database).
+ ///
+ [TestClass]
+ public class RequestValidatorInstanceUnitTests
+ {
+ private const string ENTITY_NAME = "entity";
+
+ #region CheckFirstValidity (static)
+
+ [DataTestMethod]
+ [DataRow("5", 5)]
+ [DataRow("1", 1)]
+ [DataRow("-1", -1)]
+ public void CheckFirstValidity_Valid_ReturnsValue(string first, int expected)
+ {
+ Assert.AreEqual(expected, RequestValidator.CheckFirstValidity(first));
+ }
+
+ [DataTestMethod]
+ [DataRow("0")]
+ [DataRow("-2")]
+ [DataRow("abc")]
+ [DataRow("")]
+ public void CheckFirstValidity_Invalid_Throws(string first)
+ {
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => RequestValidator.CheckFirstValidity(first));
+ Assert.AreEqual(HttpStatusCode.BadRequest, ex.StatusCode);
+ }
+
+ #endregion
+
+ #region ValidateRequestContext
+
+ [TestMethod]
+ public void ValidateRequestContext_ValidField_DoesNotThrow()
+ {
+ Mock provider = BuildTableProvider();
+ RequestValidator validator = CreateValidator(provider);
+
+ FindRequestContext context = new(ENTITY_NAME, GetTableDbo(), isList: false)
+ {
+ FieldsToBeReturned = new List { "title" }
+ };
+
+ validator.ValidateRequestContext(context);
+ }
+
+ [TestMethod]
+ public void ValidateRequestContext_InvalidField_Throws()
+ {
+ Mock provider = BuildTableProvider();
+ RequestValidator validator = CreateValidator(provider);
+
+ FindRequestContext context = new(ENTITY_NAME, GetTableDbo(), isList: false)
+ {
+ FieldsToBeReturned = new List { "nonexistent" }
+ };
+
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => validator.ValidateRequestContext(context));
+ Assert.AreEqual(HttpStatusCode.BadRequest, ex.StatusCode);
+ }
+
+ #endregion
+
+ #region ValidateInsertRequestContext
+
+ [TestMethod]
+ public void ValidateInsertRequestContext_ValidBody_DoesNotThrow()
+ {
+ RequestValidator validator = CreateValidator(BuildTableProvider());
+ InsertRequestContext context = CreateInsertContext(@"{""title"":""Book""}");
+
+ validator.ValidateInsertRequestContext(context);
+ }
+
+ [TestMethod]
+ public void ValidateInsertRequestContext_MissingRequiredField_Throws()
+ {
+ RequestValidator validator = CreateValidator(BuildTableProvider());
+ // 'title' is non-nullable and required for the replacement-style insert.
+ InsertRequestContext context = CreateInsertContext(@"{""description"":""x""}");
+
+ Assert.ThrowsException(() => validator.ValidateInsertRequestContext(context));
+ }
+
+ [TestMethod]
+ public void ValidateInsertRequestContext_NullValueForNonNullable_Throws()
+ {
+ RequestValidator validator = CreateValidator(BuildTableProvider());
+ InsertRequestContext context = CreateInsertContext(@"{""title"":null}");
+
+ Assert.ThrowsException(() => validator.ValidateInsertRequestContext(context));
+ }
+
+ [TestMethod]
+ public void ValidateInsertRequestContext_ExtraFieldStrict_Throws()
+ {
+ RequestValidator validator = CreateValidator(BuildTableProvider(), requestBodyStrict: true);
+ InsertRequestContext context = CreateInsertContext(@"{""title"":""Book"",""unexpected"":""x""}");
+
+ Assert.ThrowsException(() => validator.ValidateInsertRequestContext(context));
+ }
+
+ [TestMethod]
+ public void ValidateInsertRequestContext_ExtraFieldNonStrict_DoesNotThrow()
+ {
+ RequestValidator validator = CreateValidator(BuildTableProvider(), requestBodyStrict: false);
+ InsertRequestContext context = CreateInsertContext(@"{""title"":""Book"",""unexpected"":""x""}");
+
+ validator.ValidateInsertRequestContext(context);
+ }
+
+ [TestMethod]
+ public void ValidateInsertRequestContext_ReadOnlyFieldInBodyStrict_Throws()
+ {
+ RequestValidator validator = CreateValidator(BuildTableProviderWithReadOnlyColumn());
+ InsertRequestContext context = CreateInsertContext(@"{""title"":""Book"",""computed"":""x""}");
+
+ Assert.ThrowsException(() => validator.ValidateInsertRequestContext(context));
+ }
+
+ #endregion
+
+ #region ValidateStoredProcedureRequestContext
+
+ [TestMethod]
+ public void ValidateStoredProcedureRequestContext_ValidParameters_DoesNotThrow()
+ {
+ RequestValidator validator = CreateValidator(BuildStoredProcedureProvider());
+ StoredProcedureRequestContext context = CreateSpContext(@"{""param1"":""value""}");
+
+ validator.ValidateStoredProcedureRequestContext(context);
+ }
+
+ [TestMethod]
+ public void ValidateStoredProcedureRequestContext_ExtraParameterStrict_Throws()
+ {
+ RequestValidator validator = CreateValidator(BuildStoredProcedureProvider(), requestBodyStrict: true);
+ StoredProcedureRequestContext context = CreateSpContext(@"{""param1"":""value"",""extra"":""x""}");
+
+ Assert.ThrowsException(() => validator.ValidateStoredProcedureRequestContext(context));
+ }
+
+ [TestMethod]
+ public void ValidateStoredProcedureRequestContext_ExtraParameterNonStrict_DoesNotThrow()
+ {
+ RequestValidator validator = CreateValidator(BuildStoredProcedureProvider(), requestBodyStrict: false);
+ StoredProcedureRequestContext context = CreateSpContext(@"{""param1"":""value"",""extra"":""x""}");
+
+ validator.ValidateStoredProcedureRequestContext(context);
+ }
+
+ #endregion
+
+ #region ValidateEntity
+
+ [TestMethod]
+ public void ValidateEntity_ExistingEntity_DoesNotThrow()
+ {
+ Mock provider = BuildTableProvider();
+ provider.Setup(x => x.GetLinkingEntities()).Returns(new Dictionary());
+ RequestValidator validator = CreateValidator(provider);
+
+ validator.ValidateEntity(ENTITY_NAME);
+ }
+
+ [TestMethod]
+ public void ValidateEntity_UnknownEntity_Throws()
+ {
+ Mock provider = new();
+ provider.Setup(x => x.EntityToDatabaseObject).Returns(new Dictionary());
+ provider.Setup(x => x.GetLinkingEntities()).Returns(new Dictionary());
+ RequestValidator validator = CreateValidator(provider);
+
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => validator.ValidateEntity(ENTITY_NAME));
+ Assert.AreEqual(HttpStatusCode.NotFound, ex.StatusCode);
+ }
+
+ [TestMethod]
+ public void ValidateEntity_LinkingEntity_Throws()
+ {
+ Mock provider = BuildTableProvider();
+ provider.Setup(x => x.GetLinkingEntities()).Returns(new Dictionary
+ {
+ [ENTITY_NAME] = null!
+ });
+ RequestValidator validator = CreateValidator(provider);
+
+ Assert.ThrowsException(() => validator.ValidateEntity(ENTITY_NAME));
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private static DatabaseTable GetTableDbo() =>
+ new(schemaName: "dbo", tableName: "tbl") { TableDefinition = new SourceDefinition() };
+
+ private static InsertRequestContext CreateInsertContext(string json) =>
+ new(ENTITY_NAME, GetTableDbo(), ParsePayload(json), EntityActionOperation.Insert);
+
+ private static StoredProcedureRequestContext CreateSpContext(string json)
+ {
+ DatabaseStoredProcedure dbo = new(schemaName: "dbo", tableName: "sp")
+ {
+ SourceType = EntitySourceType.StoredProcedure,
+ StoredProcedureDefinition = new StoredProcedureDefinition()
+ };
+ StoredProcedureRequestContext context = new(ENTITY_NAME, dbo, ParsePayload(json), EntityActionOperation.Execute);
+ context.PopulateResolvedParameters();
+ return context;
+ }
+
+ private static JsonElement ParsePayload(string json) => JsonDocument.Parse(json).RootElement.Clone();
+
+ private static SourceDefinition CreateTableSourceDefinition()
+ {
+ SourceDefinition sourceDefinition = new() { PrimaryKey = new List { "id" } };
+ sourceDefinition.Columns.Add("id", new ColumnDefinition { SystemType = typeof(int), IsAutoGenerated = true, IsNullable = false });
+ sourceDefinition.Columns.Add("title", new ColumnDefinition { SystemType = typeof(string), IsNullable = false });
+ sourceDefinition.Columns.Add("description", new ColumnDefinition { SystemType = typeof(string), IsNullable = true });
+ return sourceDefinition;
+ }
+
+ private static Mock BuildTableProvider() => BuildProviderForSourceDefinition(CreateTableSourceDefinition());
+
+ private static Mock BuildTableProviderWithReadOnlyColumn()
+ {
+ SourceDefinition sourceDefinition = CreateTableSourceDefinition();
+ sourceDefinition.Columns.Add("computed", new ColumnDefinition { SystemType = typeof(string), IsReadOnly = true, IsNullable = true });
+ return BuildProviderForSourceDefinition(sourceDefinition);
+ }
+
+ private static Mock BuildProviderForSourceDefinition(SourceDefinition sourceDefinition)
+ {
+ Mock provider = new();
+ provider.Setup(x => x.GetSourceDefinition(It.IsAny())).Returns(sourceDefinition);
+ provider.Setup(x => x.EntityToDatabaseObject).Returns(new Dictionary
+ {
+ [ENTITY_NAME] = new DatabaseTable("dbo", "tbl") { TableDefinition = sourceDefinition }
+ });
+
+ foreach (string column in sourceDefinition.Columns.Keys)
+ {
+ string exposed = column;
+ string backing = column;
+ provider.Setup(x => x.TryGetExposedColumnName(It.IsAny(), column, out exposed)).Returns(true);
+ provider.Setup(x => x.TryGetBackingColumn(It.IsAny(), column, out backing)).Returns(true);
+ }
+
+ return provider;
+ }
+
+ private static Mock BuildStoredProcedureProvider()
+ {
+ StoredProcedureDefinition spDefinition = new()
+ {
+ Parameters = new Dictionary
+ {
+ ["param1"] = new ParameterDefinition { SystemType = typeof(string) }
+ }
+ };
+
+ Mock provider = new();
+ provider.Setup(x => x.GetStoredProcedureDefinition(It.IsAny())).Returns(spDefinition);
+ return provider;
+ }
+
+ private static RequestValidator CreateValidator(Mock provider, bool requestBodyStrict = true)
+ {
+ RuntimeConfig config = new(
+ Schema: "test-schema",
+ DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "Server=test;", Options: null),
+ Runtime: new(
+ Rest: new(Enabled: true, Path: "/api", RequestBodyStrict: requestBodyStrict),
+ GraphQL: new(),
+ Mcp: null,
+ Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)),
+ Entities: new(new Dictionary
+ {
+ [ENTITY_NAME] = new Entity(
+ Source: new(ENTITY_NAME, EntitySourceType.Table, null, null),
+ GraphQL: new(ENTITY_NAME, ENTITY_NAME + "s"),
+ Fields: null,
+ Rest: new(Enabled: true),
+ Permissions: System.Array.Empty(),
+ Mappings: null,
+ Relationships: null,
+ Mcp: null)
+ }));
+
+ RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config);
+ Mock factory = new();
+ factory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(provider.Object);
+ return new RequestValidator(factory.Object, configProvider);
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Service.Tests/UnitTests/RequestValidatorStaticUnitTests.cs b/src/Service.Tests/UnitTests/RequestValidatorStaticUnitTests.cs
new file mode 100644
index 0000000000..d8b9bfb214
--- /dev/null
+++ b/src/Service.Tests/UnitTests/RequestValidatorStaticUnitTests.cs
@@ -0,0 +1,157 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Net;
+using System.Text.Json;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Services;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for the static request-validation helpers on
+ /// which validate URL components and request bodies independent of database metadata.
+ ///
+ [TestClass]
+ public class RequestValidatorStaticUnitTests
+ {
+ #region ValidateStoredProcedureRequest
+
+ [DataTestMethod]
+ [DataRow(null)]
+ [DataRow("")]
+ [DataRow(" ")]
+ public void ValidateStoredProcedureRequest_NoPrimaryKeyRoute_DoesNotThrow(string primaryKeyRoute)
+ {
+ // Should not throw for empty/whitespace primary key routes.
+ RequestValidator.ValidateStoredProcedureRequest(primaryKeyRoute);
+ }
+
+ [TestMethod]
+ public void ValidateStoredProcedureRequest_WithPrimaryKeyRoute_Throws()
+ {
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => RequestValidator.ValidateStoredProcedureRequest("id/1"));
+
+ Assert.AreEqual(HttpStatusCode.BadRequest, ex.StatusCode);
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.BadRequest, ex.SubStatusCode);
+ }
+
+ #endregion
+
+ #region ValidatePrimaryKeyRouteAndQueryStringInURL
+
+ [TestMethod]
+ public void ValidatePkRouteAndQueryString_Insert_NoRouteNoQuery_DoesNotThrow()
+ {
+ RequestValidator.ValidatePrimaryKeyRouteAndQueryStringInURL(
+ EntityActionOperation.Insert, primaryKeyRoute: null, queryString: null);
+ }
+
+ [TestMethod]
+ public void ValidatePkRouteAndQueryString_Insert_WithPrimaryKeyRoute_Throws()
+ {
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => RequestValidator.ValidatePrimaryKeyRouteAndQueryStringInURL(
+ EntityActionOperation.Insert, primaryKeyRoute: "id/1", queryString: null));
+
+ Assert.AreEqual(RequestValidator.PRIMARY_KEY_INVALID_USAGE_ERR_MESSAGE, ex.Message);
+ Assert.AreEqual(HttpStatusCode.BadRequest, ex.StatusCode);
+ }
+
+ [TestMethod]
+ public void ValidatePkRouteAndQueryString_Insert_WithQueryString_Throws()
+ {
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => RequestValidator.ValidatePrimaryKeyRouteAndQueryStringInURL(
+ EntityActionOperation.Insert, primaryKeyRoute: null, queryString: "?$filter=id eq 1"));
+
+ Assert.AreEqual(RequestValidator.QUERY_STRING_INVALID_USAGE_ERR_MESSAGE, ex.Message);
+ Assert.AreEqual(HttpStatusCode.BadRequest, ex.StatusCode);
+ }
+
+ [DataTestMethod]
+ [DataRow(EntityActionOperation.Delete)]
+ [DataRow(EntityActionOperation.Update)]
+ [DataRow(EntityActionOperation.UpdateIncremental)]
+ [DataRow(EntityActionOperation.Upsert)]
+ [DataRow(EntityActionOperation.UpsertIncremental)]
+ public void ValidatePkRouteAndQueryString_MutationRequiringKey_MissingRoute_Throws(EntityActionOperation operation)
+ {
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => RequestValidator.ValidatePrimaryKeyRouteAndQueryStringInURL(
+ operation, primaryKeyRoute: null, queryString: null));
+
+ Assert.AreEqual(RequestValidator.PRIMARY_KEY_NOT_PROVIDED_ERR_MESSAGE, ex.Message);
+ Assert.AreEqual(HttpStatusCode.BadRequest, ex.StatusCode);
+ }
+
+ [DataTestMethod]
+ [DataRow(EntityActionOperation.Delete)]
+ [DataRow(EntityActionOperation.Update)]
+ [DataRow(EntityActionOperation.Upsert)]
+ public void ValidatePkRouteAndQueryString_MutationRequiringKey_WithRoute_DoesNotThrow(EntityActionOperation operation)
+ {
+ RequestValidator.ValidatePrimaryKeyRouteAndQueryStringInURL(
+ operation, primaryKeyRoute: "id/1", queryString: null);
+ }
+
+ [TestMethod]
+ public void ValidatePkRouteAndQueryString_UnsupportedOperation_Throws()
+ {
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => RequestValidator.ValidatePrimaryKeyRouteAndQueryStringInURL(
+ EntityActionOperation.Read, primaryKeyRoute: "id/1", queryString: null));
+
+ Assert.AreEqual(HttpStatusCode.InternalServerError, ex.StatusCode);
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.UnexpectedError, ex.SubStatusCode);
+ }
+
+ #endregion
+
+ #region ValidateAndParseRequestBody
+
+ [TestMethod]
+ public void ValidateAndParseRequestBody_ValidObject_ReturnsObjectElement()
+ {
+ JsonElement result = RequestValidator.ValidateAndParseRequestBody(@"{""title"":""Hello""}");
+
+ Assert.AreEqual(JsonValueKind.Object, result.ValueKind);
+ Assert.AreEqual("Hello", result.GetProperty("title").GetString());
+ }
+
+ [DataTestMethod]
+ [DataRow("")]
+ [DataRow(null)]
+ public void ValidateAndParseRequestBody_EmptyOrNull_ReturnsDefaultElement(string requestBody)
+ {
+ JsonElement result = RequestValidator.ValidateAndParseRequestBody(requestBody);
+
+ Assert.AreEqual(JsonValueKind.Undefined, result.ValueKind);
+ }
+
+ [TestMethod]
+ public void ValidateAndParseRequestBody_JsonArray_ThrowsBatchUnsupported()
+ {
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => RequestValidator.ValidateAndParseRequestBody(@"[{""title"":""A""},{""title"":""B""}]"));
+
+ Assert.AreEqual(RequestValidator.BATCH_MUTATION_UNSUPPORTED_ERR_MESSAGE, ex.Message);
+ Assert.AreEqual(HttpStatusCode.BadRequest, ex.StatusCode);
+ }
+
+ [TestMethod]
+ public void ValidateAndParseRequestBody_InvalidJson_ThrowsBadRequest()
+ {
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => RequestValidator.ValidateAndParseRequestBody(@"{""title"":""Hello"));
+
+ Assert.AreEqual(RequestValidator.REQUEST_BODY_INVALID_JSON_ERR_MESSAGE, ex.Message);
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.BadRequest, ex.SubStatusCode);
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Service.Tests/UnitTests/RuntimeCacheOptionsConverterTests.cs b/src/Service.Tests/UnitTests/RuntimeCacheOptionsConverterTests.cs
new file mode 100644
index 0000000000..3f7be1f661
--- /dev/null
+++ b/src/Service.Tests/UnitTests/RuntimeCacheOptionsConverterTests.cs
@@ -0,0 +1,164 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#nullable disable
+
+using System.Text.Json;
+using Azure.DataApiBuilder.Config;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json.Linq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for RuntimeCacheOptionsConverterFactory and
+ /// RuntimeCacheLevel2OptionsConverterFactory covering JSON read/write of the
+ /// runtime (L1) and L2 cache options.
+ ///
+ [TestClass]
+ public class RuntimeCacheOptionsConverterTests
+ {
+ private static JsonSerializerOptions GetOptions()
+ {
+ return RuntimeConfigLoader.GetSerializationOptions();
+ }
+
+ [TestMethod]
+ public void Deserialize_RuntimeCache_AllProperties()
+ {
+ string json = @"{
+ ""enabled"": true,
+ ""ttl-seconds"": 30,
+ ""level-2"": {
+ ""enabled"": true,
+ ""provider"": ""redis"",
+ ""connection-string"": ""localhost:6379"",
+ ""partition"": ""dab""
+ }
+ }";
+
+ RuntimeCacheOptions options = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(options);
+ Assert.IsTrue(options.Enabled ?? false);
+ Assert.AreEqual(30, options.TtlSeconds);
+ Assert.IsTrue(options.UserProvidedTtlOptions);
+ Assert.IsNotNull(options.Level2);
+ Assert.IsTrue(options.Level2.Enabled ?? false);
+ Assert.AreEqual("redis", options.Level2.Provider);
+ Assert.AreEqual("localhost:6379", options.Level2.ConnectionString);
+ Assert.AreEqual("dab", options.Level2.Partition);
+ }
+
+ [TestMethod]
+ public void Deserialize_RuntimeCache_NoTtl_UsesDefault()
+ {
+ RuntimeCacheOptions options = JsonSerializer.Deserialize(@"{ ""enabled"": true }", GetOptions());
+
+ Assert.IsNotNull(options);
+ Assert.AreEqual(RuntimeCacheOptions.DEFAULT_TTL_SECONDS, options.TtlSeconds);
+ Assert.IsFalse(options.UserProvidedTtlOptions);
+ }
+
+ [DataTestMethod]
+ [DataRow(@"{ ""ttl-seconds"": 0 }", DisplayName = "Zero ttl-seconds")]
+ [DataRow(@"{ ""ttl-seconds"": -10 }", DisplayName = "Negative ttl-seconds")]
+ public void Deserialize_RuntimeCache_InvalidTtl_ThrowsJsonException(string json)
+ {
+ Assert.ThrowsException(
+ () => JsonSerializer.Deserialize(json, GetOptions()));
+ }
+
+ [TestMethod]
+ public void Serialize_RuntimeCache_UserProvidedTtl_IsWritten()
+ {
+ RuntimeCacheOptions options = new(Enabled: true, TtlSeconds: 45);
+
+ string json = JsonSerializer.Serialize(options, GetOptions());
+ JObject jObject = JObject.Parse(json);
+
+ Assert.IsTrue(jObject["enabled"].Value());
+ Assert.AreEqual(45, jObject["ttl-seconds"].Value());
+ }
+
+ [TestMethod]
+ public void Serialize_RuntimeCache_NoUserTtl_OmitsTtl()
+ {
+ RuntimeCacheOptions options = new(Enabled: false);
+
+ string json = JsonSerializer.Serialize(options, GetOptions());
+ JObject jObject = JObject.Parse(json);
+
+ Assert.IsFalse(jObject["enabled"].Value());
+ Assert.IsFalse(jObject.ContainsKey("ttl-seconds"));
+ }
+
+ [TestMethod]
+ public void Serialize_RuntimeCache_WithLevel2_WritesLevel2()
+ {
+ RuntimeCacheOptions options = new(Enabled: true, TtlSeconds: 10)
+ {
+ Level2 = new RuntimeCacheLevel2Options(Enabled: true, Provider: "redis")
+ };
+
+ string json = JsonSerializer.Serialize(options, GetOptions());
+ JObject jObject = JObject.Parse(json);
+
+ Assert.IsTrue(jObject.ContainsKey("level-2"));
+ Assert.AreEqual("redis", jObject["level-2"]["provider"].Value());
+ }
+
+ [TestMethod]
+ public void Deserialize_Level2_AllProperties()
+ {
+ string json = @"{
+ ""enabled"": true,
+ ""provider"": ""redis"",
+ ""connection-string"": ""localhost:6379"",
+ ""partition"": ""p1""
+ }";
+
+ RuntimeCacheLevel2Options level2 = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.IsNotNull(level2);
+ Assert.IsTrue(level2.Enabled ?? false);
+ Assert.AreEqual("redis", level2.Provider);
+ Assert.AreEqual("localhost:6379", level2.ConnectionString);
+ Assert.AreEqual("p1", level2.Partition);
+ }
+
+ [TestMethod]
+ public void Serialize_Level2_OnlyWritesProvidedProperties()
+ {
+ RuntimeCacheLevel2Options level2 = new(Enabled: false);
+
+ string json = JsonSerializer.Serialize(level2, GetOptions());
+ JObject jObject = JObject.Parse(json);
+
+ Assert.IsFalse(jObject["enabled"].Value());
+ Assert.IsFalse(jObject.ContainsKey("provider"));
+ Assert.IsFalse(jObject.ContainsKey("connection-string"));
+ Assert.IsFalse(jObject.ContainsKey("partition"));
+ }
+
+ [TestMethod]
+ public void RoundTrip_RuntimeCache_PreservesValues()
+ {
+ RuntimeCacheOptions original = new(Enabled: true, TtlSeconds: 25)
+ {
+ Level2 = new RuntimeCacheLevel2Options(Enabled: true, Provider: "redis", ConnectionString: "localhost:6379", Partition: "p")
+ };
+
+ string json = JsonSerializer.Serialize(original, GetOptions());
+ RuntimeCacheOptions result = JsonSerializer.Deserialize(json, GetOptions());
+
+ Assert.AreEqual(original.Enabled, result.Enabled);
+ Assert.AreEqual(original.TtlSeconds, result.TtlSeconds);
+ Assert.IsNotNull(result.Level2);
+ Assert.AreEqual(original.Level2.Provider, result.Level2.Provider);
+ Assert.AreEqual(original.Level2.ConnectionString, result.Level2.ConnectionString);
+ Assert.AreEqual(original.Level2.Partition, result.Level2.Partition);
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/RuntimeConfigProviderUnitTests.cs b/src/Service.Tests/UnitTests/RuntimeConfigProviderUnitTests.cs
new file mode 100644
index 0000000000..ff41107730
--- /dev/null
+++ b/src/Service.Tests/UnitTests/RuntimeConfigProviderUnitTests.cs
@@ -0,0 +1,91 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Configurations;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for accessor and access-token behavior
+ /// using an in-memory loaded configuration.
+ ///
+ [TestClass]
+ public class RuntimeConfigProviderUnitTests
+ {
+ [TestMethod]
+ public void TryGetConfig_LoadedProvider_ReturnsTrue()
+ {
+ RuntimeConfigProvider provider = CreateProvider();
+
+ Assert.IsTrue(provider.TryGetConfig(out RuntimeConfig? config));
+ Assert.IsNotNull(config);
+ }
+
+ [TestMethod]
+ public void TryGetLoadedConfig_BeforeLoad_ReturnsFalse_AfterLoad_ReturnsTrue()
+ {
+ RuntimeConfigProvider provider = CreateProvider();
+
+ // Before any load is triggered, no config has been loaded yet.
+ Assert.IsFalse(provider.TryGetLoadedConfig(out RuntimeConfig? notLoaded));
+ Assert.IsNull(notLoaded);
+
+ // GetConfig triggers the lazy load and persists it on the loader.
+ provider.GetConfig();
+
+ Assert.IsTrue(provider.TryGetLoadedConfig(out RuntimeConfig? loaded));
+ Assert.IsNotNull(loaded);
+ }
+
+ [TestMethod]
+ public void GetConfig_LoadedProvider_ReturnsConfig()
+ {
+ RuntimeConfigProvider provider = CreateProvider();
+
+ RuntimeConfig config = provider.GetConfig();
+ Assert.IsNotNull(config);
+ }
+
+ [TestMethod]
+ public void TrySetAccesstoken_ExistingDataSource_StoresTokenAndReturnsTrue()
+ {
+ RuntimeConfigProvider provider = CreateProvider();
+ string dataSourceName = provider.GetConfig().DefaultDataSourceName;
+
+ bool result = provider.TrySetAccesstoken("test-token", dataSourceName);
+
+ Assert.IsTrue(result);
+ Assert.AreEqual("test-token", provider.ManagedIdentityAccessToken[dataSourceName]);
+ }
+
+ [TestMethod]
+ public void TrySetAccesstoken_UnknownDataSource_ReturnsFalse()
+ {
+ RuntimeConfigProvider provider = CreateProvider();
+ // Force the config to load so the data-source existence check runs.
+ provider.GetConfig();
+
+ bool result = provider.TrySetAccesstoken("test-token", "nonexistent-data-source");
+
+ Assert.IsFalse(result);
+ }
+
+ private static RuntimeConfigProvider CreateProvider()
+ {
+ RuntimeConfig config = new(
+ Schema: "test-schema",
+ DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "Server=test;", Options: null),
+ Runtime: new(
+ Rest: new(),
+ GraphQL: new(),
+ Mcp: null,
+ Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)),
+ Entities: new(new Dictionary()));
+
+ return TestHelper.GenerateInMemoryRuntimeConfigProvider(config);
+ }
+ }
+}
diff --git a/src/Service.Tests/UnitTests/RuntimeConfigValidatorUnitTests.cs b/src/Service.Tests/UnitTests/RuntimeConfigValidatorUnitTests.cs
new file mode 100644
index 0000000000..1302c02695
--- /dev/null
+++ b/src/Service.Tests/UnitTests/RuntimeConfigValidatorUnitTests.cs
@@ -0,0 +1,421 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using System.IO.Abstractions.TestingHelpers;
+using Azure.DataApiBuilder.Config;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Config.ObjectModel.Embeddings;
+using Azure.DataApiBuilder.Core.Configurations;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Microsoft.Extensions.Logging;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for URI/policy validation helpers on that
+ /// operate purely on a (no database metadata required).
+ ///
+ [TestClass]
+ public class RuntimeConfigValidatorUnitTests
+ {
+ #region IsValidDatabasePolicyForAction
+
+ [TestMethod]
+ public void IsValidDatabasePolicyForAction_CreateWithDatabasePolicy_ReturnsFalse()
+ {
+ EntityAction action = new(
+ Action: EntityActionOperation.Create,
+ Fields: null,
+ Policy: new EntityActionPolicy(Database: "@item.id eq 1"));
+
+ Assert.IsFalse(RuntimeConfigValidator.IsValidDatabasePolicyForAction(action));
+ }
+
+ [TestMethod]
+ public void IsValidDatabasePolicyForAction_CreateWithoutPolicy_ReturnsTrue()
+ {
+ EntityAction action = new(Action: EntityActionOperation.Create, Fields: null, Policy: null);
+ Assert.IsTrue(RuntimeConfigValidator.IsValidDatabasePolicyForAction(action));
+ }
+
+ [TestMethod]
+ public void IsValidDatabasePolicyForAction_CreateWithEmptyDatabasePolicy_ReturnsTrue()
+ {
+ EntityAction action = new(
+ Action: EntityActionOperation.Create,
+ Fields: null,
+ Policy: new EntityActionPolicy(Database: " "));
+
+ Assert.IsTrue(RuntimeConfigValidator.IsValidDatabasePolicyForAction(action));
+ }
+
+ [DataTestMethod]
+ [DataRow(EntityActionOperation.Read)]
+ [DataRow(EntityActionOperation.Update)]
+ [DataRow(EntityActionOperation.Delete)]
+ public void IsValidDatabasePolicyForAction_NonCreateWithDatabasePolicy_ReturnsTrue(EntityActionOperation operation)
+ {
+ EntityAction action = new(
+ Action: operation,
+ Fields: null,
+ Policy: new EntityActionPolicy(Database: "@item.id eq 1"));
+
+ Assert.IsTrue(RuntimeConfigValidator.IsValidDatabasePolicyForAction(action));
+ }
+
+ #endregion
+
+ #region ValidateRestURI
+
+ [TestMethod]
+ public void ValidateRestURI_ValidPath_DoesNotThrow()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ validator.ValidateRestURI(ConfigWith(rest: new RestRuntimeOptions(Enabled: true, Path: "/api")));
+ }
+
+ [DataTestMethod]
+ [DataRow("apiWithoutLeadingSlash", DisplayName = "No leading slash")]
+ [DataRow("/api v2", DisplayName = "Contains whitespace")]
+ public void ValidateRestURI_InvalidPath_Throws(string path)
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => validator.ValidateRestURI(ConfigWith(rest: new RestRuntimeOptions(Enabled: true, Path: path))));
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode);
+ }
+
+ #endregion
+
+ #region ValidateGraphQLURI
+
+ [TestMethod]
+ public void ValidateGraphQLURI_ValidPath_DoesNotThrow()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ validator.ValidateGraphQLURI(ConfigWith(graphQL: new GraphQLRuntimeOptions(Path: "/graphql")));
+ }
+
+ [TestMethod]
+ public void ValidateGraphQLURI_InvalidPath_Throws()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => validator.ValidateGraphQLURI(ConfigWith(graphQL: new GraphQLRuntimeOptions(Path: "graphqlNoSlash"))));
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode);
+ }
+
+ #endregion
+
+ #region ValidateMcpUri
+
+ [TestMethod]
+ public void ValidateMcpUri_McpNotConfigured_DoesNotThrow()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ validator.ValidateMcpUri(ConfigWith(mcp: null));
+ }
+
+ [TestMethod]
+ public void ValidateMcpUri_ValidPath_DoesNotThrow()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ validator.ValidateMcpUri(ConfigWith(mcp: new McpRuntimeOptions(Enabled: true, Path: "/mcp", DmlTools: null)));
+ }
+
+ [TestMethod]
+ public void ValidateMcpUri_EmptyPath_Throws()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => validator.ValidateMcpUri(ConfigWith(mcp: new McpRuntimeOptions(Enabled: true, Path: "", DmlTools: null))));
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode);
+ }
+
+ [TestMethod]
+ public void ValidateMcpUri_InvalidPath_Throws()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => validator.ValidateMcpUri(ConfigWith(mcp: new McpRuntimeOptions(Enabled: true, Path: "mcpNoSlash", DmlTools: null))));
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode);
+ }
+
+ #endregion
+
+ #region ValidateAppInsightsTelemetryConnectionString
+
+ [TestMethod]
+ public void ValidateAppInsights_EnabledWithoutConnectionString_Throws()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ TelemetryOptions telemetry = new(ApplicationInsights: new ApplicationInsightsOptions(Enabled: true, ConnectionString: ""));
+
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => validator.ValidateAppInsightsTelemetryConnectionString(ConfigWith(telemetry: telemetry)));
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode);
+ }
+
+ [TestMethod]
+ public void ValidateAppInsights_EnabledWithConnectionString_DoesNotThrow()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ TelemetryOptions telemetry = new(ApplicationInsights: new ApplicationInsightsOptions(Enabled: true, ConnectionString: "InstrumentationKey=abc"));
+
+ validator.ValidateAppInsightsTelemetryConnectionString(ConfigWith(telemetry: telemetry));
+ }
+
+ [TestMethod]
+ public void ValidateAppInsights_Disabled_DoesNotThrow()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ TelemetryOptions telemetry = new(ApplicationInsights: new ApplicationInsightsOptions(Enabled: false, ConnectionString: null));
+
+ validator.ValidateAppInsightsTelemetryConnectionString(ConfigWith(telemetry: telemetry));
+ }
+
+ #endregion
+
+ #region ValidateAzureLogAnalyticsAuth
+
+ [TestMethod]
+ public void ValidateAzureLogAnalyticsAuth_EnabledWithoutAuth_Throws()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ TelemetryOptions telemetry = new(AzureLogAnalytics: new AzureLogAnalyticsOptions { Enabled = true, Auth = null });
+
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => validator.ValidateAzureLogAnalyticsAuth(ConfigWith(telemetry: telemetry)));
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode);
+ }
+
+ [TestMethod]
+ public void ValidateAzureLogAnalyticsAuth_EnabledWithCompleteAuth_DoesNotThrow()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ AzureLogAnalyticsAuthOptions auth = new()
+ {
+ CustomTableName = "DabLogs",
+ DcrImmutableId = "dcr-123",
+ DceEndpoint = "https://dce.example.com"
+ };
+ TelemetryOptions telemetry = new(AzureLogAnalytics: new AzureLogAnalyticsOptions { Enabled = true, Auth = auth });
+
+ validator.ValidateAzureLogAnalyticsAuth(ConfigWith(telemetry: telemetry));
+ }
+
+ [TestMethod]
+ public void ValidateAzureLogAnalyticsAuth_Disabled_DoesNotThrow()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ TelemetryOptions telemetry = new(AzureLogAnalytics: new AzureLogAnalyticsOptions { Enabled = false, Auth = null });
+
+ validator.ValidateAzureLogAnalyticsAuth(ConfigWith(telemetry: telemetry));
+ }
+
+ #endregion
+
+ #region ValidateFileSinkPath
+
+ [TestMethod]
+ public void ValidateFileSinkPath_EnabledWithoutPath_Throws()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ TelemetryOptions telemetry = new(File: new FileSinkOptions { Enabled = true, Path = "" });
+
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => validator.ValidateFileSinkPath(ConfigWith(telemetry: telemetry)));
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode);
+ }
+
+ [TestMethod]
+ public void ValidateFileSinkPath_EnabledWithValidPath_DoesNotThrow()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ TelemetryOptions telemetry = new(File: new FileSinkOptions { Enabled = true, Path = "logs/dab-log.txt" });
+
+ validator.ValidateFileSinkPath(ConfigWith(telemetry: telemetry));
+ }
+
+ [TestMethod]
+ public void ValidateFileSinkPath_Disabled_DoesNotThrow()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ TelemetryOptions telemetry = new(File: new FileSinkOptions { Enabled = false, Path = "logs/dab-log.txt" });
+
+ validator.ValidateFileSinkPath(ConfigWith(telemetry: telemetry));
+ }
+
+ #endregion
+
+ #region ValidateEmbeddingsOptions
+
+ [TestMethod]
+ public void ValidateEmbeddings_NotConfigured_DoesNotThrow()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ validator.ValidateEmbeddingsOptions(ConfigWith(embeddings: null));
+ }
+
+ [TestMethod]
+ public void ValidateEmbeddings_Disabled_DoesNotThrow()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ EmbeddingsOptions embeddings = new(Provider: EmbeddingProviderType.OpenAI, BaseUrl: "https://api.openai.com", ApiKey: "key", Enabled: false);
+
+ validator.ValidateEmbeddingsOptions(ConfigWith(embeddings: embeddings));
+ }
+
+ [TestMethod]
+ public void ValidateEmbeddings_EmptyBaseUrl_Throws()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ EmbeddingsOptions embeddings = new(Provider: EmbeddingProviderType.OpenAI, BaseUrl: "", ApiKey: "key", Enabled: true);
+
+ Assert.ThrowsException(
+ () => validator.ValidateEmbeddingsOptions(ConfigWith(embeddings: embeddings)));
+ }
+
+ [TestMethod]
+ public void ValidateEmbeddings_InvalidBaseUrl_Throws()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ EmbeddingsOptions embeddings = new(Provider: EmbeddingProviderType.OpenAI, BaseUrl: "not-a-url", ApiKey: "key", Enabled: true);
+
+ Assert.ThrowsException(
+ () => validator.ValidateEmbeddingsOptions(ConfigWith(embeddings: embeddings)));
+ }
+
+ [TestMethod]
+ public void ValidateEmbeddings_EmptyApiKey_Throws()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ EmbeddingsOptions embeddings = new(Provider: EmbeddingProviderType.OpenAI, BaseUrl: "https://api.openai.com", ApiKey: "", Enabled: true);
+
+ Assert.ThrowsException(
+ () => validator.ValidateEmbeddingsOptions(ConfigWith(embeddings: embeddings)));
+ }
+
+ [TestMethod]
+ public void ValidateEmbeddings_AzureOpenAIWithoutModel_Throws()
+ {
+ RuntimeConfigValidator validator = CreateValidator();
+ EmbeddingsOptions embeddings = new(Provider: EmbeddingProviderType.AzureOpenAI, BaseUrl: "https://x.openai.azure.com", ApiKey: "key", Enabled: true, Model: null);
+
+ Assert.ThrowsException