From 4218260b01c5602a82e410a03bcf578a1730b1c6 Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Tue, 7 Jul 2026 15:35:34 -0700 Subject: [PATCH 1/7] add code coverage to easy to cover branches --- .../Mcp/BuiltInDmlToolValidationTests.cs | 314 ++++++++++++++++++ .../Mcp/McpArgumentParserTests.cs | 221 ++++++++++++ .../Mcp/McpAuthorizationHelperTests.cs | 120 +++++++ src/Service.Tests/Mcp/McpErrorHelpersTests.cs | 78 +++++ src/Service.Tests/Mcp/McpJsonHelperTests.cs | 107 ++++++ .../Mcp/McpResponseBuilderTests.cs | 125 +++++++ .../Mcp/McpTelemetryHelperTests.cs | 179 ++++++++++ .../AKVRetryPolicyOptionsConverterTests.cs | 152 +++++++++ .../AzureKeyVaultOptionsConverterTests.cs | 133 ++++++++ .../UnitTests/BoolJsonConverterTests.cs | 60 ++++ .../CompressionOptionsConverterTests.cs | 121 +++++++ ...izationVariableReplacementSettingsTests.cs | 221 ++++++++++++ .../UnitTests/DmlToolsConfigConverterTests.cs | 242 ++++++++++++++ .../EmbeddingsEndpointOptionsTests.cs | 70 ++++ .../EmbeddingsOptionsConverterTests.cs | 208 ++++++++++++ .../UnitTests/EntitySourceNamesParserTests.cs | 75 +++++ .../UnitTests/EnumExtensionsTests.cs | 69 ++++ .../HttpStatusCodeExtensionsTests.cs | 27 ++ .../UnitTests/MergeJsonProviderTests.cs | 105 ++++++ .../UnitTests/ProductInfoTests.cs | 68 ++++ .../RuntimeCacheOptionsConverterTests.cs | 163 +++++++++ .../UnitTests/TypeHelperTests.cs | 158 +++++++++ .../Utf8JsonReaderExtensionsTests.cs | 43 +++ 23 files changed, 3059 insertions(+) create mode 100644 src/Service.Tests/Mcp/BuiltInDmlToolValidationTests.cs create mode 100644 src/Service.Tests/Mcp/McpArgumentParserTests.cs create mode 100644 src/Service.Tests/Mcp/McpAuthorizationHelperTests.cs create mode 100644 src/Service.Tests/Mcp/McpErrorHelpersTests.cs create mode 100644 src/Service.Tests/Mcp/McpJsonHelperTests.cs create mode 100644 src/Service.Tests/Mcp/McpResponseBuilderTests.cs create mode 100644 src/Service.Tests/Mcp/McpTelemetryHelperTests.cs create mode 100644 src/Service.Tests/UnitTests/AKVRetryPolicyOptionsConverterTests.cs create mode 100644 src/Service.Tests/UnitTests/AzureKeyVaultOptionsConverterTests.cs create mode 100644 src/Service.Tests/UnitTests/BoolJsonConverterTests.cs create mode 100644 src/Service.Tests/UnitTests/CompressionOptionsConverterTests.cs create mode 100644 src/Service.Tests/UnitTests/DeserializationVariableReplacementSettingsTests.cs create mode 100644 src/Service.Tests/UnitTests/DmlToolsConfigConverterTests.cs create mode 100644 src/Service.Tests/UnitTests/EmbeddingsEndpointOptionsTests.cs create mode 100644 src/Service.Tests/UnitTests/EmbeddingsOptionsConverterTests.cs create mode 100644 src/Service.Tests/UnitTests/EntitySourceNamesParserTests.cs create mode 100644 src/Service.Tests/UnitTests/EnumExtensionsTests.cs create mode 100644 src/Service.Tests/UnitTests/HttpStatusCodeExtensionsTests.cs create mode 100644 src/Service.Tests/UnitTests/MergeJsonProviderTests.cs create mode 100644 src/Service.Tests/UnitTests/ProductInfoTests.cs create mode 100644 src/Service.Tests/UnitTests/RuntimeCacheOptionsConverterTests.cs create mode 100644 src/Service.Tests/UnitTests/TypeHelperTests.cs create mode 100644 src/Service.Tests/UnitTests/Utf8JsonReaderExtensionsTests.cs 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..850d1475f1 --- /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 string error); + + 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/McpResponseBuilderTests.cs b/src/Service.Tests/Mcp/McpResponseBuilderTests.cs new file mode 100644 index 0000000000..0780ad6108 --- /dev/null +++ b/src/Service.Tests/Mcp/McpResponseBuilderTests.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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_ReturnsDouble() + { + JsonElement number = JsonDocument.Parse("42").RootElement; + + // The ternary in GetJsonValue unifies long and double to double. + Assert.AreEqual(42d, 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..ba61f75bc6 --- /dev/null +++ b/src/Service.Tests/UnitTests/AKVRetryPolicyOptionsConverterTests.cs @@ -0,0 +1,152 @@ +// 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..664665f287 --- /dev/null +++ b/src/Service.Tests/UnitTests/AzureKeyVaultOptionsConverterTests.cs @@ -0,0 +1,133 @@ +// 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..f55f80a034 --- /dev/null +++ b/src/Service.Tests/UnitTests/BoolJsonConverterTests.cs @@ -0,0 +1,60 @@ +// 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..86cff09526 --- /dev/null +++ b/src/Service.Tests/UnitTests/CompressionOptionsConverterTests.cs @@ -0,0 +1,121 @@ +// 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..fb55142ea9 --- /dev/null +++ b/src/Service.Tests/UnitTests/DmlToolsConfigConverterTests.cs @@ -0,0 +1,242 @@ +// 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/EmbeddingsEndpointOptionsTests.cs b/src/Service.Tests/UnitTests/EmbeddingsEndpointOptionsTests.cs new file mode 100644 index 0000000000..6262bd1ab5 --- /dev/null +++ b/src/Service.Tests/UnitTests/EmbeddingsEndpointOptionsTests.cs @@ -0,0 +1,70 @@ +// 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..a6f2f95ea7 --- /dev/null +++ b/src/Service.Tests/UnitTests/EmbeddingsOptionsConverterTests.cs @@ -0,0 +1,208 @@ +// 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/EntitySourceNamesParserTests.cs b/src/Service.Tests/UnitTests/EntitySourceNamesParserTests.cs new file mode 100644 index 0000000000..dc6b91eb9f --- /dev/null +++ b/src/Service.Tests/UnitTests/EntitySourceNamesParserTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.DataApiBuilder.Core.Parsers; +using Azure.DataApiBuilder.Service.Exceptions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.UnitTests +{ + /// + /// Unit tests for . + /// Pure logic; no database required. + /// + [TestClass] + public class EntitySourceNamesParserTests + { + [TestMethod] + public void ParseSchemaAndTable_TableOnly_UsesDefaultSchema() + { + (string? schema, string table) = EntitySourceNamesParser.ParseSchemaAndTable("books"); + + Assert.AreEqual(string.Empty, schema); + Assert.AreEqual("books", table); + } + + [TestMethod] + public void ParseSchemaAndTable_SchemaAndTable_AreSplit() + { + (string? schema, string table) = EntitySourceNamesParser.ParseSchemaAndTable("dbo.books"); + + Assert.AreEqual("dbo", schema); + Assert.AreEqual("books", table); + } + + [TestMethod] + public void ParseSchemaAndTable_BracketedSchemaWithDot_IsParsed() + { + (string? schema, string table) = EntitySourceNamesParser.ParseSchemaAndTable("[sch.ema].books"); + + Assert.AreEqual("sch.ema", schema); + Assert.AreEqual("books", table); + } + + [TestMethod] + public void ParseSchemaAndTable_BracketedTableWithDot_IsParsed() + { + (string? schema, string table) = EntitySourceNamesParser.ParseSchemaAndTable("dbo.[ta.ble]"); + + Assert.AreEqual("dbo", schema); + Assert.AreEqual("ta.ble", table); + } + + [TestMethod] + public void ParseSchemaAndTable_EscapedBrackets_ReturnsRawInnerContent() + { + (string? schema, string table) = EntitySourceNamesParser.ParseSchemaAndTable("[sche]]ma].[ta.ble]"); + + Assert.AreEqual("sche]]ma", schema); + Assert.AreEqual("ta.ble", table); + } + + [DataTestMethod] + [DataRow("", DisplayName = "Empty input")] + [DataRow("books.", DisplayName = "Ends with dot")] + [DataRow("a.b.c", DisplayName = "More than two tokens")] + [DataRow("abc]xyz", DisplayName = "Unbracketed token with bracket char")] + [DataRow("[abc]xyz", DisplayName = "Invalid character after closing bracket")] + [DataRow("[abcdef", DisplayName = "No closing bracket")] + public void ParseSchemaAndTable_InvalidInput_Throws(string input) + { + Assert.ThrowsException( + () => EntitySourceNamesParser.ParseSchemaAndTable(input)); + } + } +} diff --git a/src/Service.Tests/UnitTests/EnumExtensionsTests.cs b/src/Service.Tests/UnitTests/EnumExtensionsTests.cs new file mode 100644 index 0000000000..65d13b1a0e --- /dev/null +++ b/src/Service.Tests/UnitTests/EnumExtensionsTests.cs @@ -0,0 +1,69 @@ +// 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..438b7eae58 --- /dev/null +++ b/src/Service.Tests/UnitTests/HttpStatusCodeExtensionsTests.cs @@ -0,0 +1,27 @@ +// 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..6d010ff09c --- /dev/null +++ b/src/Service.Tests/UnitTests/MergeJsonProviderTests.cs @@ -0,0 +1,105 @@ +// 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/ProductInfoTests.cs b/src/Service.Tests/UnitTests/ProductInfoTests.cs new file mode 100644 index 0000000000..11d7dea963 --- /dev/null +++ b/src/Service.Tests/UnitTests/ProductInfoTests.cs @@ -0,0 +1,68 @@ +// 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/RuntimeCacheOptionsConverterTests.cs b/src/Service.Tests/UnitTests/RuntimeCacheOptionsConverterTests.cs new file mode 100644 index 0000000000..9601a001ce --- /dev/null +++ b/src/Service.Tests/UnitTests/RuntimeCacheOptionsConverterTests.cs @@ -0,0 +1,163 @@ +// 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/TypeHelperTests.cs b/src/Service.Tests/UnitTests/TypeHelperTests.cs new file mode 100644 index 0000000000..5dc7e7ec66 --- /dev/null +++ b/src/Service.Tests/UnitTests/TypeHelperTests.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Data; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Core.Services.OpenAPI; +using Azure.DataApiBuilder.Service.Exceptions; +using HotChocolate.Language; +using Microsoft.OData.Edm; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.UnitTests +{ + /// + /// Unit tests for type-mapping helpers. + /// Pure logic; no database required. + /// + [TestClass] + public class TypeHelperTests + { + [DataTestMethod] + [DataRow(typeof(string), EdmPrimitiveTypeKind.String)] + [DataRow(typeof(Guid), EdmPrimitiveTypeKind.Guid)] + [DataRow(typeof(byte), EdmPrimitiveTypeKind.Byte)] + [DataRow(typeof(short), EdmPrimitiveTypeKind.Int16)] + [DataRow(typeof(int), EdmPrimitiveTypeKind.Int32)] + [DataRow(typeof(long), EdmPrimitiveTypeKind.Int64)] + [DataRow(typeof(float), EdmPrimitiveTypeKind.Single)] + [DataRow(typeof(double), EdmPrimitiveTypeKind.Double)] + [DataRow(typeof(decimal), EdmPrimitiveTypeKind.Decimal)] + [DataRow(typeof(bool), EdmPrimitiveTypeKind.Boolean)] + [DataRow(typeof(DateTime), EdmPrimitiveTypeKind.DateTimeOffset)] + [DataRow(typeof(DateTimeOffset), EdmPrimitiveTypeKind.DateTimeOffset)] + [DataRow(typeof(TimeOnly), EdmPrimitiveTypeKind.TimeOfDay)] + [DataRow(typeof(TimeSpan), EdmPrimitiveTypeKind.TimeOfDay)] + public void GetEdmPrimitiveTypeFromSystemType_MapsExpectedKind(Type systemType, EdmPrimitiveTypeKind expected) + { + Assert.AreEqual(expected, TypeHelper.GetEdmPrimitiveTypeFromSystemType(systemType)); + } + + [TestMethod] + public void GetEdmPrimitiveTypeFromSystemType_ArrayType_UsesElementType() + { + Assert.AreEqual(EdmPrimitiveTypeKind.Int32, TypeHelper.GetEdmPrimitiveTypeFromSystemType(typeof(int[]))); + } + + [TestMethod] + public void GetEdmPrimitiveTypeFromSystemType_AbstractArray_DefaultsToString() + { + Assert.AreEqual(EdmPrimitiveTypeKind.String, TypeHelper.GetEdmPrimitiveTypeFromSystemType(typeof(Array))); + } + + [TestMethod] + public void GetEdmPrimitiveTypeFromSystemType_UnsupportedType_Throws() + { + Assert.ThrowsException( + () => TypeHelper.GetEdmPrimitiveTypeFromSystemType(typeof(System.Text.StringBuilder))); + } + + [DataTestMethod] + [DataRow(typeof(int), JsonDataType.Integer)] + [DataRow(typeof(long), JsonDataType.Integer)] + [DataRow(typeof(double), JsonDataType.Number)] + [DataRow(typeof(decimal), JsonDataType.Number)] + [DataRow(typeof(bool), JsonDataType.Boolean)] + [DataRow(typeof(string), JsonDataType.String)] + [DataRow(typeof(Guid), JsonDataType.String)] + [DataRow(typeof(object), JsonDataType.Object)] + [DataRow(typeof(int?), JsonDataType.Integer)] + public void GetJsonDataTypeFromSystemType_MapsExpected(Type systemType, JsonDataType expected) + { + Assert.AreEqual(expected, TypeHelper.GetJsonDataTypeFromSystemType(systemType)); + } + + [TestMethod] + public void GetJsonDataTypeFromSystemType_UnmappedType_ReturnsUndefined() + { + Assert.AreEqual(JsonDataType.Undefined, TypeHelper.GetJsonDataTypeFromSystemType(typeof(System.Text.StringBuilder))); + } + + [DataTestMethod] + [DataRow(typeof(int), DbType.Int32)] + [DataRow(typeof(long), DbType.Int64)] + [DataRow(typeof(string), DbType.String)] + [DataRow(typeof(bool), DbType.Boolean)] + [DataRow(typeof(Guid), DbType.Guid)] + [DataRow(typeof(int?), DbType.Int32)] + public void GetDbTypeFromSystemType_MapsExpected(Type systemType, DbType expected) + { + Assert.AreEqual(expected, TypeHelper.GetDbTypeFromSystemType(systemType)); + } + + [TestMethod] + public void GetDbTypeFromSystemType_UnmappedType_ReturnsNull() + { + Assert.IsNull(TypeHelper.GetDbTypeFromSystemType(typeof(System.Text.StringBuilder))); + } + + [DataTestMethod] + [DataRow("bigint", typeof(long))] + [DataRow("int", typeof(int))] + [DataRow("bit", typeof(bool))] + [DataRow("nvarchar(50)", typeof(string))] + [DataRow("numeric", typeof(decimal))] + [DataRow("uniqueidentifier", typeof(Guid))] + public void GetSystemTypeFromSqlDbType_MapsExpected(string sqlDbTypeName, Type expected) + { + Assert.AreEqual(expected, TypeHelper.GetSystemTypeFromSqlDbType(sqlDbTypeName)); + } + + [TestMethod] + public void GetSystemTypeFromSqlDbType_Unsupported_Throws() + { + Assert.ThrowsException( + () => TypeHelper.GetSystemTypeFromSqlDbType("notatype")); + } + + [DataTestMethod] + [DataRow(SqlDbType.Date, true, DbType.Date)] + [DataRow(SqlDbType.DateTime2, true, DbType.DateTime2)] + [DataRow(SqlDbType.DateTimeOffset, true, DbType.DateTimeOffset)] + [DataRow(SqlDbType.Int, false, DbType.AnsiString)] + public void TryGetDbTypeFromSqlDbDateTimeType_ReturnsExpected(SqlDbType sqlDbType, bool expectedFound, DbType expectedDbType) + { + bool found = TypeHelper.TryGetDbTypeFromSqlDbDateTimeType(sqlDbType, out DbType dbType); + + Assert.AreEqual(expectedFound, found); + if (expectedFound) + { + Assert.AreEqual(expectedDbType, dbType); + } + } + + [TestMethod] + public void GetValue_ConvertsValueNodesToClrValues() + { + Assert.AreEqual(5, TypeHelper.GetValue(new IntValueNode(5))); + Assert.AreEqual(1.5d, TypeHelper.GetValue(new FloatValueNode(1.5d))); + Assert.AreEqual(true, TypeHelper.GetValue(new BooleanValueNode(true))); + Assert.AreEqual("hi", TypeHelper.GetValue(new StringValueNode("hi"))); + Assert.IsNull(TypeHelper.GetValue(NullValueNode.Default)); + } + + [DataTestMethod] + [DataRow(SyntaxKind.IntValue, true)] + [DataRow(SyntaxKind.FloatValue, true)] + [DataRow(SyntaxKind.BooleanValue, true)] + [DataRow(SyntaxKind.StringValue, true)] + [DataRow(SyntaxKind.NullValue, true)] + [DataRow(SyntaxKind.ListValue, false)] + [DataRow(SyntaxKind.ObjectValue, false)] + public void IsPrimitiveType_ReturnsExpected(SyntaxKind kind, bool expected) + { + Assert.AreEqual(expected, TypeHelper.IsPrimitiveType(kind)); + } + } +} diff --git a/src/Service.Tests/UnitTests/Utf8JsonReaderExtensionsTests.cs b/src/Service.Tests/UnitTests/Utf8JsonReaderExtensionsTests.cs new file mode 100644 index 0000000000..49c42c6f87 --- /dev/null +++ b/src/Service.Tests/UnitTests/Utf8JsonReaderExtensionsTests.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using Azure.DataApiBuilder.Config.Converters; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.UnitTests; + +/// +/// Unit tests for Utf8JsonReaderExtensions.DeserializeString. +/// Pure logic; no database required. +/// +[TestClass] +public class Utf8JsonReaderExtensionsTests +{ + private static string? ReadFirstString(string json) + { + byte[] bytes = Encoding.UTF8.GetBytes(json); + Utf8JsonReader reader = new(bytes); + reader.Read(); + return reader.DeserializeString(replacementSettings: null); + } + + [TestMethod] + public void DeserializeString_StringToken_ReturnsValue() + { + Assert.AreEqual("hello", ReadFirstString("\"hello\"")); + } + + [TestMethod] + public void DeserializeString_NullToken_ReturnsNull() + { + Assert.IsNull(ReadFirstString("null")); + } + + [TestMethod] + public void DeserializeString_NonStringToken_ThrowsJsonException() + { + Assert.ThrowsException(() => ReadFirstString("42")); + } +} From 280fe81eaccb3d59ecc8e9abb3fe44181ce7dd86 Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Tue, 7 Jul 2026 17:07:00 -0700 Subject: [PATCH 2/7] Remove redundant EntitySourceNamesParserTests Duplicates the existing EntitySourceNamesParserUnitTests, which already covers ParseSchemaAndTable comprehensively. --- .../UnitTests/EntitySourceNamesParserTests.cs | 75 ------------------- 1 file changed, 75 deletions(-) delete mode 100644 src/Service.Tests/UnitTests/EntitySourceNamesParserTests.cs diff --git a/src/Service.Tests/UnitTests/EntitySourceNamesParserTests.cs b/src/Service.Tests/UnitTests/EntitySourceNamesParserTests.cs deleted file mode 100644 index dc6b91eb9f..0000000000 --- a/src/Service.Tests/UnitTests/EntitySourceNamesParserTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Azure.DataApiBuilder.Core.Parsers; -using Azure.DataApiBuilder.Service.Exceptions; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Azure.DataApiBuilder.Service.Tests.UnitTests -{ - /// - /// Unit tests for . - /// Pure logic; no database required. - /// - [TestClass] - public class EntitySourceNamesParserTests - { - [TestMethod] - public void ParseSchemaAndTable_TableOnly_UsesDefaultSchema() - { - (string? schema, string table) = EntitySourceNamesParser.ParseSchemaAndTable("books"); - - Assert.AreEqual(string.Empty, schema); - Assert.AreEqual("books", table); - } - - [TestMethod] - public void ParseSchemaAndTable_SchemaAndTable_AreSplit() - { - (string? schema, string table) = EntitySourceNamesParser.ParseSchemaAndTable("dbo.books"); - - Assert.AreEqual("dbo", schema); - Assert.AreEqual("books", table); - } - - [TestMethod] - public void ParseSchemaAndTable_BracketedSchemaWithDot_IsParsed() - { - (string? schema, string table) = EntitySourceNamesParser.ParseSchemaAndTable("[sch.ema].books"); - - Assert.AreEqual("sch.ema", schema); - Assert.AreEqual("books", table); - } - - [TestMethod] - public void ParseSchemaAndTable_BracketedTableWithDot_IsParsed() - { - (string? schema, string table) = EntitySourceNamesParser.ParseSchemaAndTable("dbo.[ta.ble]"); - - Assert.AreEqual("dbo", schema); - Assert.AreEqual("ta.ble", table); - } - - [TestMethod] - public void ParseSchemaAndTable_EscapedBrackets_ReturnsRawInnerContent() - { - (string? schema, string table) = EntitySourceNamesParser.ParseSchemaAndTable("[sche]]ma].[ta.ble]"); - - Assert.AreEqual("sche]]ma", schema); - Assert.AreEqual("ta.ble", table); - } - - [DataTestMethod] - [DataRow("", DisplayName = "Empty input")] - [DataRow("books.", DisplayName = "Ends with dot")] - [DataRow("a.b.c", DisplayName = "More than two tokens")] - [DataRow("abc]xyz", DisplayName = "Unbracketed token with bracket char")] - [DataRow("[abc]xyz", DisplayName = "Invalid character after closing bracket")] - [DataRow("[abcdef", DisplayName = "No closing bracket")] - public void ParseSchemaAndTable_InvalidInput_Throws(string input) - { - Assert.ThrowsException( - () => EntitySourceNamesParser.ParseSchemaAndTable(input)); - } - } -} From 2f3352a14df512c0be0a4edc1fe8a4fda6ceaab2 Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Tue, 7 Jul 2026 17:09:34 -0700 Subject: [PATCH 3/7] fix format --- src/Service.Tests/Mcp/McpArgumentParserTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service.Tests/Mcp/McpArgumentParserTests.cs b/src/Service.Tests/Mcp/McpArgumentParserTests.cs index 850d1475f1..6054cd7983 100644 --- a/src/Service.Tests/Mcp/McpArgumentParserTests.cs +++ b/src/Service.Tests/Mcp/McpArgumentParserTests.cs @@ -211,7 +211,7 @@ public void TryParseExecuteArguments_NoParameters_ReturnsEmptyDictionary() { bool ok = McpArgumentParser.TryParseExecuteArguments( Parse(@"{ ""entity"": ""GetBooks"" }"), - out string entity, out Dictionary parameters, out string error); + out string entity, out Dictionary parameters, out _); Assert.IsTrue(ok); Assert.AreEqual("GetBooks", entity); From c28b6f1f943ac96885af0f192d61a2fd38e88932 Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Tue, 7 Jul 2026 17:15:46 -0700 Subject: [PATCH 4/7] fix assertion typing --- src/Service.Tests/Mcp/McpResponseBuilderTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Service.Tests/Mcp/McpResponseBuilderTests.cs b/src/Service.Tests/Mcp/McpResponseBuilderTests.cs index 0780ad6108..6b502a9d63 100644 --- a/src/Service.Tests/Mcp/McpResponseBuilderTests.cs +++ b/src/Service.Tests/Mcp/McpResponseBuilderTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.Text.Json; using Azure.DataApiBuilder.Mcp.Utils; @@ -114,12 +115,11 @@ public void ExtractResultJson_UnsupportedResult_ReturnsEmptyObject() } [TestMethod] - public void GetJsonValue_Number_ReturnsDouble() + public void GetJsonValue_Number_PreservesNumericValue() { JsonElement number = JsonDocument.Parse("42").RootElement; - // The ternary in GetJsonValue unifies long and double to double. - Assert.AreEqual(42d, McpResponseBuilder.GetJsonValue(number)); + Assert.AreEqual(42m, Convert.ToDecimal(McpResponseBuilder.GetJsonValue(number))); } } } From a9ccbc938b3c1a0424d5e5cbcd6b6e66f25ea271 Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 08:29:10 -0700 Subject: [PATCH 5/7] increase branch coverage --- .../GraphQLBuilder/GraphQLNamingTests.cs | 169 +++++++++ .../GraphQLBuilder/GraphQLUtilsTests.cs | 158 ++++++++ ...aphQLStoredProcedureBuilderHelpersTests.cs | 59 +++ .../Sql/SchemaConverterTypeMappingTests.cs | 92 +++++ .../Mcp/McpMetadataHelperTests.cs | 202 ++++++++++ .../UnitTests/OpenApiDocumentorUnitTests.cs | 355 ++++++++++++++++++ .../RequestValidatorStaticUnitTests.cs | 157 ++++++++ .../RuntimeConfigProviderUnitTests.cs | 91 +++++ .../UnitTests/SqlPaginationUtilUnitTests.cs | 137 +++++++ 9 files changed, 1420 insertions(+) create mode 100644 src/Service.Tests/GraphQLBuilder/GraphQLNamingTests.cs create mode 100644 src/Service.Tests/GraphQLBuilder/GraphQLUtilsTests.cs create mode 100644 src/Service.Tests/GraphQLBuilder/Sql/GraphQLStoredProcedureBuilderHelpersTests.cs create mode 100644 src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTypeMappingTests.cs create mode 100644 src/Service.Tests/Mcp/McpMetadataHelperTests.cs create mode 100644 src/Service.Tests/UnitTests/OpenApiDocumentorUnitTests.cs create mode 100644 src/Service.Tests/UnitTests/RequestValidatorStaticUnitTests.cs create mode 100644 src/Service.Tests/UnitTests/RuntimeConfigProviderUnitTests.cs create mode 100644 src/Service.Tests/UnitTests/SqlPaginationUtilUnitTests.cs 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/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/UnitTests/OpenApiDocumentorUnitTests.cs b/src/Service.Tests/UnitTests/OpenApiDocumentorUnitTests.cs new file mode 100644 index 0000000000..2cd8c559a2 --- /dev/null +++ b/src/Service.Tests/UnitTests/OpenApiDocumentorUnitTests.cs @@ -0,0 +1,355 @@ +// 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 _)); + } + + #region Helpers + + 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) + { + 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); + RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config); + + return new OpenApiDocumentor( + mockFactory.Object, + configProvider, + handler: null, + logger: NullLogger.Instance); + } + + private static RuntimeConfig CreateConfig(Dictionary entities, bool restEnabledGlobally) + { + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: string.Empty, Options: null), + Runtime: new( + Rest: new(Enabled: restEnabledGlobally), + GraphQL: new(), + Mcp: null, + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)), + Entities: new(entities)); + } + + #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/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/SqlPaginationUtilUnitTests.cs b/src/Service.Tests/UnitTests/SqlPaginationUtilUnitTests.cs new file mode 100644 index 0000000000..88ecdd2c29 --- /dev/null +++ b/src/Service.Tests/UnitTests/SqlPaginationUtilUnitTests.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Specialized; +using System.Text.Json; +using Azure.DataApiBuilder.Core.Resolvers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.UnitTests +{ + /// + /// Unit tests for the pure pagination helpers on . + /// + [TestClass] + public class SqlPaginationUtilUnitTests + { + [TestMethod] + public void Base64Encode_Decode_Roundtrip() + { + const string original = "Hello, World! [{\"id\":1}]"; + string encoded = SqlPaginationUtil.Base64Encode(original); + Assert.AreEqual(original, SqlPaginationUtil.Base64Decode(encoded)); + } + + [DataTestMethod] + [DataRow("abc", "YWJj")] + [DataRow("", "")] + public void Base64Encode_KnownValues(string plain, string expected) + { + Assert.AreEqual(expected, SqlPaginationUtil.Base64Encode(plain)); + } + + [DataTestMethod] + // first supplied: hasNext when record count exceeds first. + [DataRow("[1,2,3]", 2, 100u, 100u, true, DisplayName = "first=2, 3 records -> hasNext")] + [DataRow("[1,2,3]", 3, 100u, 100u, false, DisplayName = "first=3, 3 records -> no next")] + // first == -1 means client requested max page size. + [DataRow("[1,2,3]", -1, 100u, 5u, false, DisplayName = "first=-1 uses maxPageSize 5, 3 records -> no next")] + [DataRow("[1,2,3,4,5,6]", -1, 100u, 5u, true, DisplayName = "first=-1 uses maxPageSize 5, 6 records -> hasNext")] + public void HasNext_WithFirst(string json, int first, uint defaultPageSize, uint maxPageSize, bool expected) + { + using JsonDocument doc = JsonDocument.Parse(json); + Assert.AreEqual(expected, SqlPaginationUtil.HasNext(doc.RootElement, first, defaultPageSize, maxPageSize)); + } + + [DataTestMethod] + // first null falls back to default page size. + [DataRow("[1,2,3]", 2u, true, DisplayName = "default=2, 3 records -> hasNext")] + [DataRow("[1,2,3]", 5u, false, DisplayName = "default=5, 3 records -> no next")] + public void HasNext_WithoutFirst_UsesDefaultPageSize(string json, uint defaultPageSize, bool expected) + { + using JsonDocument doc = JsonDocument.Parse(json); + Assert.AreEqual(expected, SqlPaginationUtil.HasNext(doc.RootElement, first: null, defaultPageSize, maxPageSize: 100u)); + } + + [TestMethod] + public void BuildQueryStringWithAfterToken_NullParams_AppendsAfter() + { + string result = SqlPaginationUtil.BuildQueryStringWithAfterToken(null, "tok123"); + Assert.AreEqual("?$after=tok123", result); + } + + [TestMethod] + public void BuildQueryStringWithAfterToken_EmptyPayload_NoAfterAppended() + { + string result = SqlPaginationUtil.BuildQueryStringWithAfterToken(null, string.Empty); + Assert.AreEqual(string.Empty, result); + } + + [TestMethod] + public void BuildQueryStringWithAfterToken_ReplacesExistingAfter() + { + NameValueCollection parameters = new() { { "$after", "oldToken" } }; + string result = SqlPaginationUtil.BuildQueryStringWithAfterToken(parameters, "newToken"); + + StringAssert.Contains(result, "$after=newToken"); + Assert.IsFalse(result.Contains("oldToken"), "Existing $after token should be removed."); + } + + [TestMethod] + public void BuildQueryStringWithAfterToken_PreservesOtherParams() + { + NameValueCollection parameters = new() { { "$first", "5" } }; + string result = SqlPaginationUtil.BuildQueryStringWithAfterToken(parameters, "tok"); + + StringAssert.StartsWith(result, "?"); + StringAssert.Contains(result, "$after=tok"); + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + public void FormatQueryString_NullOrEmpty_ReturnsEmpty(bool useNull) + { + NameValueCollection? parameters = useNull ? null : new NameValueCollection(); + Assert.AreEqual(string.Empty, SqlPaginationUtil.FormatQueryString(parameters)); + } + + [TestMethod] + public void FormatQueryString_WithParameter_ReturnsPrefixedQueryString() + { + NameValueCollection parameters = new() { { "$filter", "id eq 1" } }; + string result = SqlPaginationUtil.FormatQueryString(parameters); + + StringAssert.StartsWith(result, "?"); + StringAssert.Contains(result, "filter"); + } + + [TestMethod] + public void GetConsolidatedNextLinkForPagination_Absolute_ReturnsFullUri() + { + JsonElement result = SqlPaginationUtil.GetConsolidatedNextLinkForPagination( + "http://localhost/api/Book", "?$after=tok", isNextLinkRelative: false); + + JsonElement.ArrayEnumerator enumerator = result.EnumerateArray(); + enumerator.MoveNext(); + string nextLink = enumerator.Current.GetProperty("nextLink").GetString()!; + + StringAssert.Contains(nextLink, "http://localhost/api/Book"); + StringAssert.Contains(nextLink, "$after=tok"); + } + + [TestMethod] + public void GetConsolidatedNextLinkForPagination_Relative_ReturnsPathAndQuery() + { + JsonElement result = SqlPaginationUtil.GetConsolidatedNextLinkForPagination( + "http://localhost/api/Book", "?$after=tok", isNextLinkRelative: true); + + JsonElement.ArrayEnumerator enumerator = result.EnumerateArray(); + enumerator.MoveNext(); + string nextLink = enumerator.Current.GetProperty("nextLink").GetString()!; + + StringAssert.StartsWith(nextLink, "/api/Book"); + Assert.IsFalse(nextLink.Contains("localhost"), "Relative link should not contain the host."); + } + } +} From 2b547cb8cf97fe7ef78822edbfc8a5ca9131eb6f Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 11:06:26 -0700 Subject: [PATCH 6/7] raise code coverage --- .../UnitTests/EdmModelBuilderTests.cs | 130 ++++++ .../UnitTests/OpenApiDocumentorUnitTests.cs | 164 ++++++- .../RequestValidatorInstanceUnitTests.cs | 323 ++++++++++++++ .../RuntimeConfigValidatorUnitTests.cs | 421 ++++++++++++++++++ .../SqlResponseHelpersMutationUnitTests.cs | 244 ++++++++++ 5 files changed, 1277 insertions(+), 5 deletions(-) create mode 100644 src/Service.Tests/UnitTests/EdmModelBuilderTests.cs create mode 100644 src/Service.Tests/UnitTests/RequestValidatorInstanceUnitTests.cs create mode 100644 src/Service.Tests/UnitTests/RuntimeConfigValidatorUnitTests.cs create mode 100644 src/Service.Tests/UnitTests/SqlResponseHelpersMutationUnitTests.cs 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/OpenApiDocumentorUnitTests.cs b/src/Service.Tests/UnitTests/OpenApiDocumentorUnitTests.cs index 2cd8c559a2..d855af2724 100644 --- a/src/Service.Tests/UnitTests/OpenApiDocumentorUnitTests.cs +++ b/src/Service.Tests/UnitTests/OpenApiDocumentorUnitTests.cs @@ -244,8 +244,159 @@ public void CreateDocument_StoredProcedureEntity_GeneratesExecutePathAndSchemas( 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) }; @@ -298,7 +449,9 @@ private static OpenApiDocumentor CreateDocumentor( Dictionary entities, Dictionary dbObjects, IEnumerable? columnNames = null, - bool restEnabledGlobally = true) + bool restEnabledGlobally = true, + bool requestBodyStrict = true, + string? baseRoute = null) { Mock mockMetadataProvider = new(); mockMetadataProvider.Setup(x => x.EntityToDatabaseObject).Returns(dbObjects); @@ -327,7 +480,7 @@ private static OpenApiDocumentor CreateDocumentor( Mock mockFactory = new(); mockFactory.Setup(x => x.GetMetadataProvider(It.IsAny())).Returns(mockMetadataProvider.Object); - RuntimeConfig config = CreateConfig(entities, restEnabledGlobally); + RuntimeConfig config = CreateConfig(entities, restEnabledGlobally, requestBodyStrict, baseRoute); RuntimeConfigProvider configProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config); return new OpenApiDocumentor( @@ -337,16 +490,17 @@ private static OpenApiDocumentor CreateDocumentor( logger: NullLogger.Instance); } - private static RuntimeConfig CreateConfig(Dictionary entities, bool restEnabledGlobally) + 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), + Rest: new(Enabled: restEnabledGlobally, RequestBodyStrict: requestBodyStrict), GraphQL: new(), Mcp: null, - Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)), + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development), + BaseRoute: baseRoute), Entities: new(entities)); } 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/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( + () => validator.ValidateEmbeddingsOptions(ConfigWith(embeddings: embeddings))); + } + + [TestMethod] + public void ValidateEmbeddings_ValidOpenAI_DoesNotThrow() + { + RuntimeConfigValidator validator = CreateValidator(); + EmbeddingsOptions embeddings = new(Provider: EmbeddingProviderType.OpenAI, BaseUrl: "https://api.openai.com", ApiKey: "key", Enabled: true, Model: "text-embedding-3-small"); + + validator.ValidateEmbeddingsOptions(ConfigWith(embeddings: embeddings)); + } + + #endregion + + #region ValidateGlobalEndpointRouteConfig + + [TestMethod] + public void ValidateGlobalEndpointRouteConfig_AllEndpointsDisabled_Throws() + { + RuntimeConfigValidator validator = CreateValidator(); + // MCP defaults to enabled when null, so it must be explicitly disabled here. + RuntimeConfig config = ConfigWith( + rest: new RestRuntimeOptions(Enabled: false), + graphQL: new GraphQLRuntimeOptions(Enabled: false), + mcp: new McpRuntimeOptions(Enabled: false, Path: "/mcp", DmlTools: null)); + + DataApiBuilderException ex = Assert.ThrowsException( + () => validator.ValidateGlobalEndpointRouteConfig(config)); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode); + } + + [TestMethod] + public void ValidateGlobalEndpointRouteConfig_ConflictingRestAndGraphQLPaths_Throws() + { + RuntimeConfigValidator validator = CreateValidator(); + RuntimeConfig config = ConfigWith( + rest: new RestRuntimeOptions(Enabled: true, Path: "/samepath"), + graphQL: new GraphQLRuntimeOptions(Enabled: true, Path: "/samepath"), + mcp: null); + + DataApiBuilderException ex = Assert.ThrowsException( + () => validator.ValidateGlobalEndpointRouteConfig(config)); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode); + } + + [TestMethod] + public void ValidateGlobalEndpointRouteConfig_InvalidBaseRoute_Throws() + { + RuntimeConfigValidator validator = CreateValidator(); + // A base route without a leading slash fails URI validation regardless of auth provider. + RuntimeConfig config = ConfigWith( + rest: new RestRuntimeOptions(Enabled: true, Path: "/api"), + graphQL: new GraphQLRuntimeOptions(Enabled: true, Path: "/graphql"), + baseRoute: "baseWithoutLeadingSlash"); + + DataApiBuilderException ex = Assert.ThrowsException( + () => validator.ValidateGlobalEndpointRouteConfig(config)); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode); + } + + [TestMethod] + public void ValidateGlobalEndpointRouteConfig_DistinctEnabledPaths_DoesNotThrow() + { + RuntimeConfigValidator validator = CreateValidator(); + RuntimeConfig config = ConfigWith( + rest: new RestRuntimeOptions(Enabled: true, Path: "/api"), + graphQL: new GraphQLRuntimeOptions(Enabled: true, Path: "/graphql"), + mcp: new McpRuntimeOptions(Enabled: true, Path: "/mcp", DmlTools: null)); + + validator.ValidateGlobalEndpointRouteConfig(config); + } + + #endregion + + #region Helpers + + private static RuntimeConfigValidator CreateValidator() + { + MockFileSystem fileSystem = new(); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + RuntimeConfigProvider provider = new(loader); + return new RuntimeConfigValidator(provider, fileSystem, new Mock>().Object); + } + + private static RuntimeConfig ConfigWith( + RestRuntimeOptions? rest = null, + GraphQLRuntimeOptions? graphQL = null, + McpRuntimeOptions? mcp = null, + TelemetryOptions? telemetry = null, + EmbeddingsOptions? embeddings = null, + string? baseRoute = null) + { + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "Server=test;", Options: null), + Runtime: new( + Rest: rest ?? new RestRuntimeOptions(), + GraphQL: graphQL ?? new GraphQLRuntimeOptions(), + Mcp: mcp, + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development), + BaseRoute: baseRoute, + Telemetry: telemetry, + Embeddings: embeddings), + Entities: new(new Dictionary())); + } + + #endregion + } +} diff --git a/src/Service.Tests/UnitTests/SqlResponseHelpersMutationUnitTests.cs b/src/Service.Tests/UnitTests/SqlResponseHelpersMutationUnitTests.cs new file mode 100644 index 0000000000..a50c89c477 --- /dev/null +++ b/src/Service.Tests/UnitTests/SqlResponseHelpersMutationUnitTests.cs @@ -0,0 +1,244 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Text.Json; +using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Models; +using Azure.DataApiBuilder.Core.Resolvers; +using Azure.DataApiBuilder.Core.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Azure.DataApiBuilder.Service.Tests.UnitTests +{ + /// + /// Unit tests for the mutation-response and primary-key-route helpers on + /// (the existing SqlResponseHelpersUnitTests covers FormatFindResult). + /// + [TestClass] + public class SqlResponseHelpersMutationUnitTests + { + private const string ENTITY_NAME = "Book"; + + #region OkResponse / OkMutationResponse + + [TestMethod] + public void OkResponse_SingleObject_WrapsInValueArray() + { + using JsonDocument doc = JsonDocument.Parse(@"{""id"":1}"); + OkObjectResult result = SqlResponseHelpers.OkResponse(doc.RootElement.Clone()); + + Assert.AreEqual(1, GetValueArrayLength(result.Value)); + } + + [TestMethod] + public void OkResponse_Array_PreservesElements() + { + using JsonDocument doc = JsonDocument.Parse(@"[{""id"":1},{""id"":2}]"); + OkObjectResult result = SqlResponseHelpers.OkResponse(doc.RootElement.Clone()); + + Assert.AreEqual(2, GetValueArrayLength(result.Value)); + } + + [TestMethod] + public void OkMutationResponse_Dictionary_WrapsSingleRow() + { + Dictionary row = new() { ["id"] = 1, ["title"] = "Book" }; + OkObjectResult result = SqlResponseHelpers.OkMutationResponse(row); + + Assert.AreEqual(1, GetValueArrayLength(result.Value)); + } + + [TestMethod] + public void OkMutationResponse_JsonObject_WrapsInArray() + { + using JsonDocument doc = JsonDocument.Parse(@"{""id"":1}"); + OkObjectResult result = SqlResponseHelpers.OkMutationResponse(doc.RootElement.Clone()); + + Assert.AreEqual(1, GetValueArrayLength(result.Value)); + } + + [TestMethod] + public void OkMutationResponse_JsonArray_PreservesElements() + { + using JsonDocument doc = JsonDocument.Parse(@"[{""id"":1},{""id"":2}]"); + OkObjectResult result = SqlResponseHelpers.OkMutationResponse(doc.RootElement.Clone()); + + Assert.AreEqual(2, GetValueArrayLength(result.Value)); + } + + #endregion + + #region ConstructPrimaryKeyRoute + + [TestMethod] + public void ConstructPrimaryKeyRoute_ViewEntity_ReturnsEmpty() + { + DatabaseView view = new(schemaName: "dbo", tableName: "v") { SourceType = EntitySourceType.View, ViewDefinition = new ViewDefinition() }; + FindRequestContext context = new(ENTITY_NAME, view, isList: false); + + string route = SqlResponseHelpers.ConstructPrimaryKeyRoute(context, new Dictionary(), BuildProvider().Object); + + Assert.AreEqual(string.Empty, route); + } + + [TestMethod] + public void ConstructPrimaryKeyRoute_TableWithPrimaryKey_ReturnsRoute() + { + FindRequestContext context = new(ENTITY_NAME, BuildTableDbo(), isList: false); + Dictionary row = new() { ["id"] = 1 }; + + string route = SqlResponseHelpers.ConstructPrimaryKeyRoute(context, row, BuildProvider().Object); + + Assert.AreEqual("id/1", route); + } + + [TestMethod] + public void ConstructPrimaryKeyRoute_MissingExposedName_ReturnsEmpty() + { + FindRequestContext context = new(ENTITY_NAME, BuildTableDbo(), isList: false); + Mock provider = new(); + provider.Setup(x => x.GetSourceDefinition(It.IsAny())).Returns(BuildSourceDefinition()); + string discard; + provider.Setup(x => x.TryGetExposedColumnName(It.IsAny(), It.IsAny(), out discard)).Returns(false); + + string route = SqlResponseHelpers.ConstructPrimaryKeyRoute(context, new Dictionary { ["id"] = 1 }, provider.Object); + + Assert.AreEqual(string.Empty, route); + } + + [TestMethod] + public void ConstructPrimaryKeyRoute_PrimaryKeyMissingFromRow_ReturnsEmpty() + { + FindRequestContext context = new(ENTITY_NAME, BuildTableDbo(), isList: false); + + string route = SqlResponseHelpers.ConstructPrimaryKeyRoute(context, new Dictionary(), BuildProvider().Object); + + Assert.AreEqual(string.Empty, route); + } + + #endregion + + #region ConstructOkMutationResponse + + [TestMethod] + public void ConstructOkMutationResponse_DbPolicyWithDocument_UsesDocument() + { + using JsonDocument selectResult = JsonDocument.Parse(@"[{""id"":9}]"); + OkObjectResult result = SqlResponseHelpers.ConstructOkMutationResponse( + new Dictionary(), selectResult, isReadPermissionConfiguredForRole: true, isDatabasePolicyDefinedForReadAction: true); + + Assert.AreEqual(1, GetValueArrayLength(result.Value)); + } + + [TestMethod] + public void ConstructOkMutationResponse_DbPolicyNullDocument_ReturnsEmpty() + { + OkObjectResult result = SqlResponseHelpers.ConstructOkMutationResponse( + new Dictionary { ["id"] = 1 }, jsonDocument: null, isReadPermissionConfiguredForRole: true, isDatabasePolicyDefinedForReadAction: true); + + Assert.AreEqual(0, GetValueArrayLength(result.Value)); + } + + [TestMethod] + public void ConstructOkMutationResponse_NoPolicyWithReadPermission_UsesResultRow() + { + Dictionary row = new() { ["id"] = 1 }; + OkObjectResult result = SqlResponseHelpers.ConstructOkMutationResponse( + row, jsonDocument: null, isReadPermissionConfiguredForRole: true, isDatabasePolicyDefinedForReadAction: false); + + Assert.AreEqual(1, GetValueArrayLength(result.Value)); + } + + [TestMethod] + public void ConstructOkMutationResponse_NoReadPermission_ReturnsEmpty() + { + Dictionary row = new() { ["id"] = 1 }; + OkObjectResult result = SqlResponseHelpers.ConstructOkMutationResponse( + row, jsonDocument: null, isReadPermissionConfiguredForRole: false, isDatabasePolicyDefinedForReadAction: false); + + Assert.AreEqual(0, GetValueArrayLength(result.Value)); + } + + #endregion + + #region ConstructCreatedResultResponse + + [TestMethod] + public void ConstructCreatedResultResponse_InsertWithPrimaryKeyRoute_SetsLocation() + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("localhost"); + httpContext.Request.Path = "/api/Book"; + + CreatedResult result = SqlResponseHelpers.ConstructCreatedResultResponse( + new Dictionary { ["id"] = 1 }, + jsonDocument: null, + primaryKeyRoute: "id/1", + isReadPermissionConfiguredForRole: true, + isDatabasePolicyDefinedForReadAction: false, + operationType: EntityActionOperation.Insert, + baseRoute: string.Empty, + httpContext: httpContext); + + StringAssert.Contains(result.Location, "id/1"); + Assert.AreEqual(1, GetValueArrayLength(result.Value)); + } + + [TestMethod] + public void ConstructCreatedResultResponse_UpsertNoRoute_EmptyLocation() + { + DefaultHttpContext httpContext = new(); + + CreatedResult result = SqlResponseHelpers.ConstructCreatedResultResponse( + new Dictionary { ["id"] = 1 }, + jsonDocument: null, + primaryKeyRoute: string.Empty, + isReadPermissionConfiguredForRole: true, + isDatabasePolicyDefinedForReadAction: false, + operationType: EntityActionOperation.Upsert, + baseRoute: string.Empty, + httpContext: httpContext); + + Assert.AreEqual(string.Empty, result.Location); + Assert.AreEqual(1, GetValueArrayLength(result.Value)); + } + + #endregion + + #region Helpers + + private static int GetValueArrayLength(object? resultValue) + { + string json = JsonSerializer.Serialize(resultValue); + using JsonDocument doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("value").GetArrayLength(); + } + + private static SourceDefinition BuildSourceDefinition() + { + SourceDefinition sourceDefinition = new() { PrimaryKey = new List { "id" } }; + sourceDefinition.Columns.Add("id", new ColumnDefinition { SystemType = typeof(int) }); + return sourceDefinition; + } + + private static DatabaseTable BuildTableDbo() => + new(schemaName: "dbo", tableName: "books") { SourceType = EntitySourceType.Table, TableDefinition = BuildSourceDefinition() }; + + private static Mock BuildProvider() + { + Mock provider = new(); + provider.Setup(x => x.GetSourceDefinition(It.IsAny())).Returns(BuildSourceDefinition()); + string exposed = "id"; + provider.Setup(x => x.TryGetExposedColumnName(It.IsAny(), "id", out exposed)).Returns(true); + return provider; + } + + #endregion + } +} From 26b6ddb018ebf72b53277b25b5d03ff7e7208df1 Mon Sep 17 00:00:00 2001 From: Aaron Burtle Date: Thu, 9 Jul 2026 17:09:09 -0700 Subject: [PATCH 7/7] Use block-scoped namespaces in new unit test files for consistency --- .../AKVRetryPolicyOptionsConverterTests.cs | 275 ++++++------- .../AzureKeyVaultOptionsConverterTests.cs | 237 +++++------ .../UnitTests/BoolJsonConverterTests.cs | 95 ++--- .../CompressionOptionsConverterTests.cs | 213 +++++----- .../UnitTests/DmlToolsConfigConverterTests.cs | 379 ++++++++--------- .../EmbeddingsEndpointOptionsTests.cs | 99 ++--- .../EmbeddingsOptionsConverterTests.cs | 385 +++++++++--------- .../UnitTests/EnumExtensionsTests.cs | 99 ++--- .../HttpStatusCodeExtensionsTests.cs | 33 +- .../UnitTests/MergeJsonProviderTests.cs | 187 ++++----- .../UnitTests/ProductInfoTests.cs | 91 +++-- .../RuntimeCacheOptionsConverterTests.cs | 257 ++++++------ .../Utf8JsonReaderExtensionsTests.cs | 57 +-- 13 files changed, 1210 insertions(+), 1197 deletions(-) diff --git a/src/Service.Tests/UnitTests/AKVRetryPolicyOptionsConverterTests.cs b/src/Service.Tests/UnitTests/AKVRetryPolicyOptionsConverterTests.cs index ba61f75bc6..b03299fc78 100644 --- a/src/Service.Tests/UnitTests/AKVRetryPolicyOptionsConverterTests.cs +++ b/src/Service.Tests/UnitTests/AKVRetryPolicyOptionsConverterTests.cs @@ -9,144 +9,145 @@ 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 +namespace Azure.DataApiBuilder.Service.Tests.UnitTests { - private static JsonSerializerOptions GetOptions() + /// + /// Unit tests for AKVRetryPolicyOptionsConverterFactory covering JSON + /// read/write of Azure Key Vault retry policy options. + /// + [TestClass] + public class AKVRetryPolicyOptionsConverterTests { - 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); + 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 index 664665f287..a2f054e0c6 100644 --- a/src/Service.Tests/UnitTests/AzureKeyVaultOptionsConverterTests.cs +++ b/src/Service.Tests/UnitTests/AzureKeyVaultOptionsConverterTests.cs @@ -9,125 +9,126 @@ 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 +namespace Azure.DataApiBuilder.Service.Tests.UnitTests { - private static JsonSerializerOptions GetOptions() + /// + /// Unit tests for AzureKeyVaultOptionsConverterFactory covering JSON + /// read/write of Azure Key Vault options including nested retry policy. + /// + [TestClass] + public class AzureKeyVaultOptionsConverterTests { - 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); + 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 index f55f80a034..93e38493a1 100644 --- a/src/Service.Tests/UnitTests/BoolJsonConverterTests.cs +++ b/src/Service.Tests/UnitTests/BoolJsonConverterTests.cs @@ -7,54 +7,55 @@ 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 +namespace Azure.DataApiBuilder.Service.Tests.UnitTests { - private static JsonSerializerOptions GetOptions() + /// + /// Unit tests for BoolJsonConverter which accepts boolean literals as well as + /// string and numeric representations ("true"/"false"/"1"/"0"). + /// + [TestClass] + public class BoolJsonConverterTests { - 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); + 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 index 86cff09526..cad0a2f0a2 100644 --- a/src/Service.Tests/UnitTests/CompressionOptionsConverterTests.cs +++ b/src/Service.Tests/UnitTests/CompressionOptionsConverterTests.cs @@ -9,113 +9,114 @@ 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 +namespace Azure.DataApiBuilder.Service.Tests.UnitTests { - private static JsonSerializerOptions GetOptions() + /// + /// Unit tests for CompressionOptionsConverterFactory covering JSON + /// read/write of HTTP response compression options. + /// + [TestClass] + public class CompressionOptionsConverterTests { - 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); + 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/DmlToolsConfigConverterTests.cs b/src/Service.Tests/UnitTests/DmlToolsConfigConverterTests.cs index fb55142ea9..75020fe951 100644 --- a/src/Service.Tests/UnitTests/DmlToolsConfigConverterTests.cs +++ b/src/Service.Tests/UnitTests/DmlToolsConfigConverterTests.cs @@ -11,232 +11,233 @@ 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 +namespace Azure.DataApiBuilder.Service.Tests.UnitTests { - 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). + /// Unit tests for DmlToolsConfigConverter covering the boolean and object JSON + /// formats supported for MCP DML tool configuration. /// - private static string SerializeWithinObject(DmlToolsConfig config) + [TestClass] + public class DmlToolsConfigConverterTests { - using MemoryStream ms = new(); - using (Utf8JsonWriter writer = new(ms)) + private static JsonSerializerOptions GetOptions() { - writer.WriteStartObject(); - JsonSerializer.Serialize(writer, config, GetOptions()); - writer.WriteEndObject(); + return RuntimeConfigLoader.GetSerializationOptions(); } - 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); - } + /// + /// 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(); + } - [TestMethod] - public void Deserialize_AggregateRecordsBoolean_IsRead() - { - string json = @"{ ""aggregate-records"": false }"; + return Encoding.UTF8.GetString(ms.ToArray()); + } - DmlToolsConfig config = JsonSerializer.Deserialize(json, GetOptions()); + [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); + } - Assert.IsNotNull(config); - Assert.IsFalse(config.AggregateRecords); - Assert.IsTrue(config.UserProvidedAggregateRecords); - } + [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_AggregateRecordsObject_ReadsEnabledAndTimeout() - { - string json = @"{ - ""aggregate-records"": { - ""enabled"": true, - ""query-timeout"": 60 - } - }"; + [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); + } - DmlToolsConfig config = JsonSerializer.Deserialize(json, GetOptions()); + [TestMethod] + public void Deserialize_AggregateRecordsBoolean_IsRead() + { + string json = @"{ ""aggregate-records"": false }"; - Assert.IsNotNull(config); - Assert.IsTrue(config.AggregateRecords); - Assert.AreEqual(60, config.AggregateRecordsQueryTimeout); - Assert.IsTrue(config.UserProvidedAggregateRecordsQueryTimeout); - } + DmlToolsConfig config = JsonSerializer.Deserialize(json, GetOptions()); - [TestMethod] - public void Deserialize_AggregateRecordsObject_NullTimeout_IsIgnored() - { - string json = @"{ - ""aggregate-records"": { - ""enabled"": false, - ""query-timeout"": null - } - }"; + Assert.IsNotNull(config); + Assert.IsFalse(config.AggregateRecords); + Assert.IsTrue(config.UserProvidedAggregateRecords); + } - DmlToolsConfig config = JsonSerializer.Deserialize(json, GetOptions()); + [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); + } - Assert.IsNotNull(config); - Assert.IsFalse(config.AggregateRecords); - Assert.IsNull(config.AggregateRecordsQueryTimeout); - } + [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 - } - }"; + [TestMethod] + public void Deserialize_AggregateRecordsObject_UnknownSubProperty_IsSkipped() + { + string json = @"{ + ""aggregate-records"": { + ""enabled"": true, + ""bogus"": 123 + } + }"; - DmlToolsConfig config = JsonSerializer.Deserialize(json, GetOptions()); + DmlToolsConfig config = JsonSerializer.Deserialize(json, GetOptions()); - Assert.IsNotNull(config); - Assert.IsTrue(config.AggregateRecords); - } + Assert.IsNotNull(config); + Assert.IsTrue(config.AggregateRecords); + } - [TestMethod] - public void Deserialize_UnknownProperty_IsSkipped() - { - string json = @"{ ""unknown-tool"": true, ""create-record"": false }"; + [TestMethod] + public void Deserialize_UnknownProperty_IsSkipped() + { + string json = @"{ ""unknown-tool"": true, ""create-record"": false }"; - DmlToolsConfig config = JsonSerializer.Deserialize(json, GetOptions()); + DmlToolsConfig config = JsonSerializer.Deserialize(json, GetOptions()); - Assert.IsNotNull(config); - Assert.IsFalse(config.CreateRecord); - } + Assert.IsNotNull(config); + Assert.IsFalse(config.CreateRecord); + } - [TestMethod] - public void Deserialize_NonBooleanForKnownProperty_ThrowsJsonException() - { - string json = @"{ ""create-record"": ""yes"" }"; + [TestMethod] + public void Deserialize_NonBooleanForKnownProperty_ThrowsJsonException() + { + string json = @"{ ""create-record"": ""yes"" }"; - Assert.ThrowsException( - () => JsonSerializer.Deserialize(json, GetOptions())); - } + Assert.ThrowsException( + () => JsonSerializer.Deserialize(json, GetOptions())); + } - [TestMethod] - public void Deserialize_AggregateRecordsInvalidType_ThrowsJsonException() - { - string json = @"{ ""aggregate-records"": ""bad"" }"; + [TestMethod] + public void Deserialize_AggregateRecordsInvalidType_ThrowsJsonException() + { + string json = @"{ ""aggregate-records"": ""bad"" }"; - Assert.ThrowsException( - () => JsonSerializer.Deserialize(json, GetOptions())); - } + Assert.ThrowsException( + () => JsonSerializer.Deserialize(json, GetOptions())); + } - [TestMethod] - public void Serialize_FromBooleanTrue_WritesBooleanForm() - { - DmlToolsConfig config = DmlToolsConfig.FromBoolean(true); + [TestMethod] + public void Serialize_FromBooleanTrue_WritesBooleanForm() + { + DmlToolsConfig config = DmlToolsConfig.FromBoolean(true); - string json = SerializeWithinObject(config); - JObject jObject = JObject.Parse(json); + 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()); - } + 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); + [TestMethod] + public void Serialize_IndividualSettings_WritesObjectForm() + { + DmlToolsConfig config = new(createRecord: false, readRecords: true); - string json = SerializeWithinObject(config); - JObject jObject = JObject.Parse(json); + 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."); - } + 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); + [TestMethod] + public void Serialize_AggregateRecordsWithTimeout_WritesObjectForm() + { + DmlToolsConfig config = new(aggregateRecords: true, aggregateRecordsQueryTimeout: 45); - string json = SerializeWithinObject(config); - JObject jObject = JObject.Parse(json); + 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()); - } + 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; + [TestMethod] + public void Serialize_DefaultConfig_WritesNothing() + { + DmlToolsConfig config = DmlToolsConfig.Default; - string json = SerializeWithinObject(config); - JObject jObject = JObject.Parse(json); + string json = SerializeWithinObject(config); + JObject jObject = JObject.Parse(json); - Assert.IsFalse(jObject.ContainsKey("dml-tools"), "Default config should not be written."); + Assert.IsFalse(jObject.ContainsKey("dml-tools"), "Default config should not be written."); + } } } diff --git a/src/Service.Tests/UnitTests/EmbeddingsEndpointOptionsTests.cs b/src/Service.Tests/UnitTests/EmbeddingsEndpointOptionsTests.cs index 6262bd1ab5..87660f0ab7 100644 --- a/src/Service.Tests/UnitTests/EmbeddingsEndpointOptionsTests.cs +++ b/src/Service.Tests/UnitTests/EmbeddingsEndpointOptionsTests.cs @@ -4,67 +4,68 @@ 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 +namespace Azure.DataApiBuilder.Service.Tests.UnitTests { - [TestMethod] - public void GetEffectiveRoles_ExplicitRoles_AreReturned() + /// + /// Unit tests for role-resolution logic + /// (GetEffectiveRoles / IsRoleAllowed). Pure logic; no database required. + /// + [TestClass] + public class EmbeddingsEndpointOptionsTests { - EmbeddingsEndpointOptions options = new(enabled: true, roles: new[] { "reader", "writer" }); + [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)); - } + 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); + [TestMethod] + public void GetEffectiveRoles_NoRoles_DevelopmentMode_ReturnsAnonymous() + { + EmbeddingsEndpointOptions options = new(enabled: true); - CollectionAssert.AreEqual(EmbeddingsEndpointOptions.DEFAULT_ROLES_DEVELOPMENT, options.GetEffectiveRoles(isDevelopmentMode: true)); - } + CollectionAssert.AreEqual(EmbeddingsEndpointOptions.DEFAULT_ROLES_DEVELOPMENT, options.GetEffectiveRoles(isDevelopmentMode: true)); + } - [TestMethod] - public void GetEffectiveRoles_NoRoles_ProductionMode_ReturnsAuthenticated() - { - EmbeddingsEndpointOptions options = new(enabled: true); + [TestMethod] + public void GetEffectiveRoles_NoRoles_ProductionMode_ReturnsAuthenticated() + { + EmbeddingsEndpointOptions options = new(enabled: true); - CollectionAssert.AreEqual(EmbeddingsEndpointOptions.DEFAULT_ROLES, options.GetEffectiveRoles(isDevelopmentMode: false)); - } + CollectionAssert.AreEqual(EmbeddingsEndpointOptions.DEFAULT_ROLES, options.GetEffectiveRoles(isDevelopmentMode: false)); + } - [TestMethod] - public void GetEffectiveRoles_EmptyRolesArray_FallsBackToDefaults() - { - EmbeddingsEndpointOptions options = new(enabled: true, roles: new string[0]); + [TestMethod] + public void GetEffectiveRoles_EmptyRolesArray_FallsBackToDefaults() + { + EmbeddingsEndpointOptions options = new(enabled: true, roles: new string[0]); - CollectionAssert.AreEqual(EmbeddingsEndpointOptions.DEFAULT_ROLES, options.GetEffectiveRoles(isDevelopmentMode: false)); - } + 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); + [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)); - } + Assert.AreEqual(expected, options.IsRoleAllowed(role, isDevelopment)); + } - [TestMethod] - public void IsRoleAllowed_ExplicitRoles_ChecksConfiguredList() - { - EmbeddingsEndpointOptions options = new(enabled: true, roles: new[] { "reader" }); + [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)); + 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 index a6f2f95ea7..4e99a7dfb1 100644 --- a/src/Service.Tests/UnitTests/EmbeddingsOptionsConverterTests.cs +++ b/src/Service.Tests/UnitTests/EmbeddingsOptionsConverterTests.cs @@ -9,200 +9,201 @@ 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 +namespace Azure.DataApiBuilder.Service.Tests.UnitTests { - private static JsonSerializerOptions GetOptions() + /// + /// Unit tests for EmbeddingsOptionsConverterFactory covering JSON read/write + /// of embedding service options, including nested endpoint, health, chunking and cache. + /// + [TestClass] + public class EmbeddingsOptionsConverterTests { - 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"": { + private static JsonSerializerOptions GetOptions() + { + return RuntimeConfigLoader.GetSerializationOptions(); + } + + [TestMethod] + public void Deserialize_AllProperties_AreReadCorrectly() + { + string json = @"{ ""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); + ""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 index 65d13b1a0e..63f8534268 100644 --- a/src/Service.Tests/UnitTests/EnumExtensionsTests.cs +++ b/src/Service.Tests/UnitTests/EnumExtensionsTests.cs @@ -6,64 +6,65 @@ 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 +namespace Azure.DataApiBuilder.Service.Tests.UnitTests { - [TestMethod] - public void Deserialize_EnumMemberValue_IsResolved() + /// + /// 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 { - Assert.AreEqual(EntitySourceType.StoredProcedure, EnumExtensions.Deserialize("stored-procedure")); - Assert.AreEqual(EntitySourceType.Table, EnumExtensions.Deserialize("table")); - } + [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)); - } + [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 Deserialize_InvalidValue_ThrowsJsonException() + { + Assert.ThrowsException( + () => EnumExtensions.Deserialize("not-a-mode")); + } - [TestMethod] - public void TryDeserialize_ValidValue_ReturnsTrue() - { - bool ok = EnumExtensions.TryDeserialize("Fixed", out AKVRetryPolicyMode? mode); + [TestMethod] + public void TryDeserialize_ValidValue_ReturnsTrue() + { + bool ok = EnumExtensions.TryDeserialize("Fixed", out AKVRetryPolicyMode? mode); - Assert.IsTrue(ok); - Assert.AreEqual(AKVRetryPolicyMode.Fixed, mode); - } + Assert.IsTrue(ok); + Assert.AreEqual(AKVRetryPolicyMode.Fixed, mode); + } - [TestMethod] - public void TryDeserialize_InvalidValue_ReturnsFalse() - { - bool ok = EnumExtensions.TryDeserialize("bogus", out AKVRetryPolicyMode? mode); + [TestMethod] + public void TryDeserialize_InvalidValue_ReturnsFalse() + { + bool ok = EnumExtensions.TryDeserialize("bogus", out AKVRetryPolicyMode? mode); - Assert.IsFalse(ok); - Assert.IsNull(mode); - } + Assert.IsFalse(ok); + Assert.IsNull(mode); + } - [TestMethod] - public void GenerateMessageForInvalidInput_ListsValidValues() - { - string message = EnumExtensions.GenerateMessageForInvalidInput("bogus"); + [TestMethod] + public void GenerateMessageForInvalidInput_ListsValidValues() + { + string message = EnumExtensions.GenerateMessageForInvalidInput("bogus"); - StringAssert.Contains(message, "bogus"); - StringAssert.Contains(message, "Fixed"); - StringAssert.Contains(message, "Exponential"); + 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 index 438b7eae58..7773e92eee 100644 --- a/src/Service.Tests/UnitTests/HttpStatusCodeExtensionsTests.cs +++ b/src/Service.Tests/UnitTests/HttpStatusCodeExtensionsTests.cs @@ -5,23 +5,24 @@ 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 +namespace Azure.DataApiBuilder.Service.Tests.UnitTests { - [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) + /// + /// Unit tests for . Pure logic; no database required. + /// + [TestClass] + public class HttpStatusCodeExtensionsTests { - Assert.AreEqual(expected, statusCode.IsClientError()); + [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 index 6d010ff09c..1a43cbc245 100644 --- a/src/Service.Tests/UnitTests/MergeJsonProviderTests.cs +++ b/src/Service.Tests/UnitTests/MergeJsonProviderTests.cs @@ -6,100 +6,101 @@ 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 +namespace Azure.DataApiBuilder.Service.Tests.UnitTests { - [TestMethod] - public void Merge_OverridesScalarProperty() + /// + /// Unit tests for which merges two JSON strings. + /// Pure logic; no database required. + /// + [TestClass] + public class MergeJsonProviderTests { - 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")); + [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/ProductInfoTests.cs b/src/Service.Tests/UnitTests/ProductInfoTests.cs index 11d7dea963..d75588d5af 100644 --- a/src/Service.Tests/UnitTests/ProductInfoTests.cs +++ b/src/Service.Tests/UnitTests/ProductInfoTests.cs @@ -5,64 +5,65 @@ 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 +namespace Azure.DataApiBuilder.Service.Tests.UnitTests { - [TestMethod] - public void GetProductVersion_ReturnsMajorMinorPatch() + /// + /// Unit tests for version and user-agent helpers. + /// Pure logic; no database required. + /// + [TestClass] + public class ProductInfoTests { - string version = ProductInfo.GetProductVersion(); + [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}'."); - } + 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); + [TestMethod] + public void GetProductVersion_WithCommitHash_ReturnsNonEmpty() + { + string version = ProductInfo.GetProductVersion(includeCommitHash: true); - Assert.IsFalse(string.IsNullOrWhiteSpace(version)); - } + Assert.IsFalse(string.IsNullOrWhiteSpace(version)); + } - [TestMethod] - public void GetDataApiBuilderUserAgent_WhenEnvNotSet_ReturnsDefault() - { - string? original = Environment.GetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV); - try + [TestMethod] + public void GetDataApiBuilderUserAgent_WhenEnvNotSet_ReturnsDefault() { - Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, null); + string? original = Environment.GetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV); + try + { + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, null); - string userAgent = ProductInfo.GetDataApiBuilderUserAgent(); + 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); + 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 + [TestMethod] + public void GetDataApiBuilderUserAgent_WhenEnvSet_ReturnsEnvValue() { - Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, "custom-agent"); + 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); + Assert.AreEqual("custom-agent", ProductInfo.GetDataApiBuilderUserAgent()); + } + finally + { + Environment.SetEnvironmentVariable(ProductInfo.DAB_APP_NAME_ENV, original); + } } } } diff --git a/src/Service.Tests/UnitTests/RuntimeCacheOptionsConverterTests.cs b/src/Service.Tests/UnitTests/RuntimeCacheOptionsConverterTests.cs index 9601a001ce..3f7be1f661 100644 --- a/src/Service.Tests/UnitTests/RuntimeCacheOptionsConverterTests.cs +++ b/src/Service.Tests/UnitTests/RuntimeCacheOptionsConverterTests.cs @@ -9,155 +9,156 @@ 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 +namespace Azure.DataApiBuilder.Service.Tests.UnitTests { - private static JsonSerializerOptions GetOptions() + /// + /// Unit tests for RuntimeCacheOptionsConverterFactory and + /// RuntimeCacheLevel2OptionsConverterFactory covering JSON read/write of the + /// runtime (L1) and L2 cache options. + /// + [TestClass] + public class RuntimeCacheOptionsConverterTests { - return RuntimeConfigLoader.GetSerializationOptions(); - } + private static JsonSerializerOptions GetOptions() + { + return RuntimeConfigLoader.GetSerializationOptions(); + } - [TestMethod] - public void Deserialize_RuntimeCache_AllProperties() - { - string json = @"{ - ""enabled"": true, - ""ttl-seconds"": 30, - ""level-2"": { + [TestMethod] + public void Deserialize_RuntimeCache_AllProperties() + { + string json = @"{ ""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()); + ""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); - } + 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())); - } + [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); + [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); + string json = JsonSerializer.Serialize(options, GetOptions()); + JObject jObject = JObject.Parse(json); - Assert.IsTrue(jObject["enabled"].Value()); - Assert.AreEqual(45, jObject["ttl-seconds"].Value()); - } + Assert.IsTrue(jObject["enabled"].Value()); + Assert.AreEqual(45, jObject["ttl-seconds"].Value()); + } - [TestMethod] - public void Serialize_RuntimeCache_NoUserTtl_OmitsTtl() - { - RuntimeCacheOptions options = new(Enabled: false); + [TestMethod] + public void Serialize_RuntimeCache_NoUserTtl_OmitsTtl() + { + RuntimeCacheOptions options = new(Enabled: false); - string json = JsonSerializer.Serialize(options, GetOptions()); - JObject jObject = JObject.Parse(json); + string json = JsonSerializer.Serialize(options, GetOptions()); + JObject jObject = JObject.Parse(json); - Assert.IsFalse(jObject["enabled"].Value()); - Assert.IsFalse(jObject.ContainsKey("ttl-seconds")); - } + 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) + [TestMethod] + public void Serialize_RuntimeCache_WithLevel2_WritesLevel2() { - Level2 = new RuntimeCacheLevel2Options(Enabled: true, Provider: "redis") - }; + 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); + 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()); - } + 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 Deserialize_Level2_AllProperties() + { + string json = @"{ + ""enabled"": true, + ""provider"": ""redis"", + ""connection-string"": ""localhost:6379"", + ""partition"": ""p1"" + }"; - [TestMethod] - public void Serialize_Level2_OnlyWritesProvidedProperties() - { - RuntimeCacheLevel2Options level2 = new(Enabled: false); + RuntimeCacheLevel2Options level2 = JsonSerializer.Deserialize(json, GetOptions()); - string json = JsonSerializer.Serialize(level2, GetOptions()); - JObject jObject = JObject.Parse(json); + Assert.IsNotNull(level2); + Assert.IsTrue(level2.Enabled ?? false); + Assert.AreEqual("redis", level2.Provider); + Assert.AreEqual("localhost:6379", level2.ConnectionString); + Assert.AreEqual("p1", level2.Partition); + } - Assert.IsFalse(jObject["enabled"].Value()); - Assert.IsFalse(jObject.ContainsKey("provider")); - Assert.IsFalse(jObject.ContainsKey("connection-string")); - Assert.IsFalse(jObject.ContainsKey("partition")); - } + [TestMethod] + public void Serialize_Level2_OnlyWritesProvidedProperties() + { + RuntimeCacheLevel2Options level2 = new(Enabled: false); - [TestMethod] - public void RoundTrip_RuntimeCache_PreservesValues() - { - RuntimeCacheOptions original = new(Enabled: true, TtlSeconds: 25) + 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() { - 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); + 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/Utf8JsonReaderExtensionsTests.cs b/src/Service.Tests/UnitTests/Utf8JsonReaderExtensionsTests.cs index 49c42c6f87..1f3c82a846 100644 --- a/src/Service.Tests/UnitTests/Utf8JsonReaderExtensionsTests.cs +++ b/src/Service.Tests/UnitTests/Utf8JsonReaderExtensionsTests.cs @@ -6,38 +6,39 @@ using Azure.DataApiBuilder.Config.Converters; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Azure.DataApiBuilder.Service.Tests.UnitTests; - -/// -/// Unit tests for Utf8JsonReaderExtensions.DeserializeString. -/// Pure logic; no database required. -/// -[TestClass] -public class Utf8JsonReaderExtensionsTests +namespace Azure.DataApiBuilder.Service.Tests.UnitTests { - private static string? ReadFirstString(string json) + /// + /// Unit tests for Utf8JsonReaderExtensions.DeserializeString. + /// Pure logic; no database required. + /// + [TestClass] + public class Utf8JsonReaderExtensionsTests { - byte[] bytes = Encoding.UTF8.GetBytes(json); - Utf8JsonReader reader = new(bytes); - reader.Read(); - return reader.DeserializeString(replacementSettings: null); - } + private static string? ReadFirstString(string json) + { + byte[] bytes = Encoding.UTF8.GetBytes(json); + Utf8JsonReader reader = new(bytes); + reader.Read(); + return reader.DeserializeString(replacementSettings: null); + } - [TestMethod] - public void DeserializeString_StringToken_ReturnsValue() - { - Assert.AreEqual("hello", ReadFirstString("\"hello\"")); - } + [TestMethod] + public void DeserializeString_StringToken_ReturnsValue() + { + Assert.AreEqual("hello", ReadFirstString("\"hello\"")); + } - [TestMethod] - public void DeserializeString_NullToken_ReturnsNull() - { - Assert.IsNull(ReadFirstString("null")); - } + [TestMethod] + public void DeserializeString_NullToken_ReturnsNull() + { + Assert.IsNull(ReadFirstString("null")); + } - [TestMethod] - public void DeserializeString_NonStringToken_ThrowsJsonException() - { - Assert.ThrowsException(() => ReadFirstString("42")); + [TestMethod] + public void DeserializeString_NonStringToken_ThrowsJsonException() + { + Assert.ThrowsException(() => ReadFirstString("42")); + } } }