diff --git a/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs new file mode 100644 index 0000000000..71ec37e0a3 --- /dev/null +++ b/src/Service.Tests/Mcp/AggregateRecordsToolMsSqlIntegrationTests.cs @@ -0,0 +1,315 @@ +// 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.Mcp.BuiltInTools; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Integration tests for AggregateRecordsTool against a real MsSql database. + /// The books table has: id (int PK), title (varchar), publisher_id (int). + /// Seed data: 21 books with publisher_ids: 1234, 2345, 2323, 2324, 1940, 1941. + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class AggregateRecordsToolMsSqlIntegrationTests : McpToolTestBase + { + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + #region COUNT Tests + + /// + /// Counts all records in the Book entity using COUNT(*). + /// + [TestMethod] + public async Task Aggregate_CountAll_ReturnsCorrectCount() + { + CallToolResult result = await ExecuteAggregateAsync("Book", "count", "*"); + + AssertSuccess(result, "COUNT(*) should succeed."); + + JsonElement root = ParseResultRoot(result); + Assert.AreEqual("Book", root.GetProperty("entity").GetString()); + JsonElement resultArray = root.GetProperty("result"); + Assert.AreEqual(JsonValueKind.Array, resultArray.ValueKind, "Simple aggregate result should be an array."); + int count = resultArray[0].GetProperty("count").GetInt32(); + Assert.IsTrue(count >= 21, + $"Expected at least 21 books (seed data), got {count}."); + } + + /// + /// Counts records with an OData filter (publisher_id eq 1234). + /// + [TestMethod] + public async Task Aggregate_CountWithFilter_ReturnsFilteredCount() + { + CallToolResult result = await ExecuteAggregateAsync( + "Book", "count", "*", filter: "publisher_id eq 1234"); + + AssertSuccess(result, "COUNT with filter should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement resultArray = root.GetProperty("result"); + int count = resultArray[0].GetProperty("count").GetInt32(); + Assert.IsTrue(count >= 10, + $"Expected at least 10 books with publisher_id=1234, got {count}."); + } + + /// + /// Counts distinct publisher_id values. + /// + [TestMethod] + public async Task Aggregate_CountDistinct_ReturnsDistinctCount() + { + CallToolResult result = await ExecuteAggregateAsync( + "Book", "count", "publisher_id", distinct: true); + + AssertSuccess(result, "COUNT DISTINCT should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement resultArray = root.GetProperty("result"); + Assert.AreEqual(JsonValueKind.Array, resultArray.ValueKind, "Result should be an array."); + Assert.IsTrue(resultArray.GetArrayLength() > 0, "Result array should not be empty."); + + JsonElement firstRow = resultArray[0]; + int count = 0; + if (firstRow.TryGetProperty("count", out JsonElement countElement)) + { + count = countElement.GetInt32(); + } + else if (firstRow.TryGetProperty("count_publisher_id", out JsonElement aliasElement)) + { + count = aliasElement.GetInt32(); + } + + Assert.IsTrue(count >= 6, $"Expected at least 6 distinct publisher_ids, got {count}."); + } + + #endregion + + #region SUM/AVG/MIN/MAX Tests + + /// + /// Validates that numeric aggregation functions (sum, avg, min, max) succeed on publisher_id. + /// + [DataTestMethod] + [DataRow("sum", "sum_publisher_id", DisplayName = "SUM of publisher_id")] + [DataRow("avg", "avg_publisher_id", DisplayName = "AVG of publisher_id")] + [DataRow("min", "min_publisher_id", DisplayName = "MIN of publisher_id")] + [DataRow("max", "max_publisher_id", DisplayName = "MAX of publisher_id")] + public async Task Aggregate_NumericFunction_ReturnsResult(string function, string expectedAlias) + { + CallToolResult result = await ExecuteAggregateAsync("Book", function, "publisher_id"); + + AssertSuccess(result, $"{function.ToUpper()} should succeed on numeric field."); + + string content = GetFirstTextContent(result); + Assert.IsFalse(string.IsNullOrWhiteSpace(content), $"Expected non-empty result for {function.ToUpper()}."); + } + + /// + /// Validates that MIN returns the expected minimum value. + /// + [TestMethod] + public async Task Aggregate_Min_ReturnsExpectedMinValue() + { + CallToolResult result = await ExecuteAggregateAsync("Book", "min", "publisher_id"); + AssertSuccess(result, "MIN should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement resultArray = root.GetProperty("result"); + Assert.AreEqual(JsonValueKind.Array, resultArray.ValueKind); + Assert.IsTrue(resultArray.GetArrayLength() > 0, "Result array should not be empty."); + Assert.AreEqual(1234, resultArray[0].GetProperty("min_publisher_id").GetInt32(), + "MIN publisher_id should be 1234 (from seed data)."); + } + + /// + /// Validates that MAX returns the expected maximum value. + /// + [TestMethod] + public async Task Aggregate_Max_ReturnsExpectedMaxValue() + { + CallToolResult result = await ExecuteAggregateAsync("Book", "max", "publisher_id"); + AssertSuccess(result, "MAX should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement resultArray = root.GetProperty("result"); + Assert.AreEqual(JsonValueKind.Array, resultArray.ValueKind); + Assert.IsTrue(resultArray.GetArrayLength() > 0, "Result array should not be empty."); + Assert.AreEqual(2345, resultArray[0].GetProperty("max_publisher_id").GetInt32(), + "MAX publisher_id should be 2345 (from seed data)."); + } + + #endregion + + #region GROUP BY Tests + + /// + /// Groups by publisher_id and counts records per group. + /// + [TestMethod] + public async Task Aggregate_GroupByWithCount_ReturnsGroupedResults() + { + CallToolResult result = await ExecuteAggregateAsync( + "Book", "count", "*", groupby: new[] { "publisher_id" }); + + AssertSuccess(result, "COUNT with GROUP BY should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement resultElement = root.GetProperty("result"); + + // Non-paginated GROUP BY returns result as an array + if (resultElement.ValueKind == JsonValueKind.Array) + { + Assert.IsTrue(resultElement.GetArrayLength() > 1, "Expected multiple groups."); + } + else if (resultElement.ValueKind == JsonValueKind.Object && + resultElement.TryGetProperty("items", out JsonElement itemsElement)) + { + Assert.IsTrue(itemsElement.GetArrayLength() > 1, "Expected multiple groups."); + } + else + { + Assert.Fail("Unexpected result shape for GROUP BY response."); + } + } + + /// + /// Groups by publisher_id with first parameter for pagination. + /// + [TestMethod] + public async Task Aggregate_GroupByWithFirst_ReturnsPaginatedResults() + { + CallToolResult result = await ExecuteAggregateAsync( + "Book", "count", "*", groupby: new[] { "publisher_id" }, first: 2); + + AssertSuccess(result, "COUNT with GROUP BY and first should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement resultElement = root.GetProperty("result"); + + // Paginated GROUP BY returns result as { items: [...], endCursor, hasNextPage } + Assert.AreEqual(JsonValueKind.Object, resultElement.ValueKind, + "Paginated GROUP BY result should be an object."); + Assert.IsTrue(resultElement.TryGetProperty("items", out JsonElement itemsElement), + "Paginated response should contain 'items' property."); + Assert.IsTrue(itemsElement.GetArrayLength() <= 2, "Expected at most 2 items when first=2."); + Assert.IsTrue(resultElement.TryGetProperty("hasNextPage", out _), + "Paginated response should include hasNextPage."); + } + + #endregion + + #region Error Cases + + /// + /// Validates error scenarios for AggregateRecordsTool. + /// + [DataTestMethod] + [DataRow("NonExistentEntity", "count", "*", "NonExistentEntity", DisplayName = "Invalid entity")] + [DataRow("Book", "sum", "nonexistent_field", null, DisplayName = "Invalid field")] + public async Task Aggregate_ErrorScenarios(string entity, string function, string field, string? expectedSubstring) + { + CallToolResult result = await ExecuteAggregateAsync(entity, function, field); + + Assert.IsTrue(result.IsError == true, $"Aggregate({entity}, {function}, {field}) should fail."); + if (expectedSubstring != null) + { + StringAssert.Contains(GetFirstTextContent(result), expectedSubstring); + } + } + + /// + /// Attempts aggregation with no arguments. + /// + [TestMethod] + public async Task Aggregate_NoArguments_ReturnsError() + { + IServiceProvider serviceProvider = BuildQueryServiceProvider(); + AggregateRecordsTool tool = new(); + + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + AssertError(result); + } + + /// + /// Attempts aggregation with an invalid function name. + /// + [TestMethod] + public async Task Aggregate_InvalidFunction_ReturnsError() + { + CallToolResult result = await ExecuteAggregateAsync("Book", "invalid_func", "*"); + + AssertError(result); + } + + #endregion + + private static async Task ExecuteAggregateAsync( + string entity, + string function, + string field, + string? filter = null, + bool distinct = false, + string[]? groupby = null, + string? orderby = null, + int? first = null, + string? after = null) + { + IServiceProvider serviceProvider = BuildQueryServiceProvider(); + AggregateRecordsTool tool = new(); + + var args = new Dictionary + { + { "entity", entity }, + { "function", function }, + { "field", field } + }; + + if (filter != null) + { + args["filter"] = filter; + } + + if (distinct) + { + args["distinct"] = true; + } + + if (groupby != null) + { + args["groupby"] = groupby; + } + + if (orderby != null) + { + args["orderby"] = orderby; + } + + if (first != null) + { + args["first"] = first; + } + + if (after != null) + { + args["after"] = after; + } + + return await ExecuteToolAsync(tool, serviceProvider, args); + } + } +} diff --git a/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs new file mode 100644 index 0000000000..d6f2ed7d8c --- /dev/null +++ b/src/Service.Tests/Mcp/CreateRecordToolMsSqlIntegrationTests.cs @@ -0,0 +1,140 @@ +// 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.Mcp.BuiltInTools; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Integration tests for CreateRecordTool against a real MsSql database. + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class CreateRecordToolMsSqlIntegrationTests : McpToolTestBase + { + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + /// + /// Creates a new book record with valid data and verifies success. + /// + [TestMethod] + public async Task CreateRecord_ValidData_ReturnsSuccess() + { + var data = new Dictionary + { + { "title", "Integration Test Book" }, + { "publisher_id", 1234 } + }; + + CallToolResult result = await ExecuteCreateAsync("Book", data); + + AssertSuccess(result, "CreateRecord should succeed with valid data."); + + JsonElement root = ParseResultRoot(result); + Assert.AreEqual("Book", root.GetProperty("entity").GetString()); + Assert.IsTrue(root.GetProperty("message").GetString()!.Contains("Successfully created"), + "Response message should indicate success."); + } + + /// + /// Creates a record and verifies the returned record contains the inserted data. + /// + [TestMethod] + public async Task CreateRecord_ReturnsCreatedData() + { + var data = new Dictionary + { + { "title", "Verify Created Data" }, + { "publisher_id", 2345 } + }; + + CallToolResult result = await ExecuteCreateAsync("Book", data); + + AssertSuccess(result, "CreateRecord should succeed."); + + JsonElement root = ParseResultRoot(result); + + Assert.IsTrue(root.TryGetProperty("result", out JsonElement resultElement), + "Response should contain 'result' property."); + + if (resultElement.ValueKind == JsonValueKind.Object && resultElement.TryGetProperty("value", out JsonElement valueArray)) + { + Assert.AreEqual(JsonValueKind.Array, valueArray.ValueKind); + Assert.IsTrue(valueArray.GetArrayLength() > 0); + JsonElement created = valueArray[0]; + Assert.AreEqual("Verify Created Data", created.GetProperty("title").GetString()); + Assert.AreEqual(2345, created.GetProperty("publisher_id").GetInt32()); + Assert.IsTrue(created.TryGetProperty("id", out _), "Created record should have an auto-generated id."); + } + } + + /// + /// Validates error scenarios for CreateRecordTool. + /// + [DataTestMethod] + [DataRow("NonExistentEntity", "NonExistentEntity", DisplayName = "Invalid entity")] + public async Task CreateRecord_InvalidEntity_ReturnsError(string entity, string expectedSubstring) + { + var data = new Dictionary { { "name", "Test" } }; + + CallToolResult result = await ExecuteCreateAsync(entity, data); + + AssertError(result, expectedSubstring); + } + + /// + /// Attempts to create a record without providing any arguments. + /// + [TestMethod] + public async Task CreateRecord_NoArguments_ReturnsError() + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + CreateRecordTool tool = new(); + + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + AssertError(result); + } + + /// + /// Attempts to create a record with missing required NOT NULL field (title). + /// + [TestMethod] + public async Task CreateRecord_MissingRequiredField_ReturnsError() + { + var data = new Dictionary + { + { "publisher_id", 1234 } + }; + + CallToolResult result = await ExecuteCreateAsync("Book", data); + + AssertError(result, message: "CreateRecord should fail when missing required NOT NULL fields."); + } + + private static async Task ExecuteCreateAsync(string entity, Dictionary data) + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + CreateRecordTool tool = new(); + + var args = new Dictionary + { + { "entity", entity }, + { "data", data } + }; + + return await ExecuteToolAsync(tool, serviceProvider, args); + } + } +} diff --git a/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs new file mode 100644 index 0000000000..73ce685a68 --- /dev/null +++ b/src/Service.Tests/Mcp/DeleteRecordToolMsSqlIntegrationTests.cs @@ -0,0 +1,180 @@ +// 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.Mcp.BuiltInTools; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Integration tests for DeleteRecordTool against a real MsSql database. + /// Tests that delete records first create a record to avoid interfering with seed data. + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class DeleteRecordToolMsSqlIntegrationTests : McpToolTestBase + { + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + /// + /// Creates a book then deletes it, verifying the delete succeeds. + /// + [TestMethod] + public async Task DeleteRecord_ExistingRecord_ReturnsSuccess() + { + int createdId = await CreateBookForDeletion("Delete Me Book"); + + var keys = new Dictionary { { "id", createdId } }; + CallToolResult result = await ExecuteDeleteAsync("Book", keys); + + AssertSuccess(result, "DeleteRecord should succeed for existing record."); + Assert.IsFalse(string.IsNullOrWhiteSpace(GetFirstTextContent(result))); + } + + /// + /// Attempts to delete a record with a non-existent key, expecting an error. + /// + [TestMethod] + public async Task DeleteRecord_NonExistentKey_ReturnsError() + { + var keys = new Dictionary { { "id", 99999 } }; + + CallToolResult result = await ExecuteDeleteAsync("Book", keys); + + AssertError(result); + } + + /// + /// Attempts to delete from a non-existent entity, expecting an error. + /// + [TestMethod] + public async Task DeleteRecord_InvalidEntity_ReturnsError() + { + var keys = new Dictionary { { "id", 1 } }; + + CallToolResult result = await ExecuteDeleteAsync("NonExistentEntity", keys); + + AssertError(result, "NonExistentEntity"); + } + + /// + /// Attempts to delete with no arguments, expecting an error. + /// + [TestMethod] + public async Task DeleteRecord_NoArguments_ReturnsError() + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + DeleteRecordTool tool = new(); + + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + AssertError(result); + } + + /// + /// Attempts to delete with null key value, expecting an error. + /// + [TestMethod] + public async Task DeleteRecord_NullKeyValue_ReturnsError() + { + var args = new Dictionary + { + { "entity", "Book" }, + { "keys", new Dictionary { { "id", null } } } + }; + + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + DeleteRecordTool tool = new(); + + string argsJson = JsonSerializer.Serialize(args); + using JsonDocument arguments = JsonDocument.Parse(argsJson); + + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + AssertError(result); + } + + /// + /// Verifies that after successful deletion, the record is no longer accessible. + /// + [TestMethod] + public async Task DeleteRecord_ThenRead_RecordNotFound() + { + int createdId = await CreateBookForDeletion("Delete Then Verify Book"); + + // Delete the book + var keys = new Dictionary { { "id", createdId } }; + CallToolResult deleteResult = await ExecuteDeleteAsync("Book", keys); + AssertSuccess(deleteResult, "Delete should succeed."); + + // Verify it's gone via read + IServiceProvider readProvider = BuildQueryServiceProvider(); + ReadRecordsTool readTool = new(); + + var readArgs = new Dictionary { { "entity", "Book" }, { "filter", $"id eq {createdId}" } }; + CallToolResult readResult = await ExecuteToolAsync(readTool, readProvider, readArgs); + + Assert.IsTrue(readResult.IsError != true, "Follow-up read after delete should succeed."); + + JsonElement root = ParseResultRoot(readResult); + JsonElement records = root.GetProperty("result").GetProperty("value"); + Assert.AreEqual(0, records.GetArrayLength(), + "Deleted record should not be found in subsequent read."); + } + + /// + /// Creates a book record using the CreateRecordTool and returns its ID. + /// + private static async Task CreateBookForDeletion(string title) + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + CreateRecordTool createTool = new(); + + var args = new Dictionary + { + { "entity", "Book" }, + { "data", new Dictionary { { "title", title }, { "publisher_id", 1234 } } } + }; + + CallToolResult createResult = await ExecuteToolAsync(createTool, serviceProvider, args); + Assert.IsTrue(createResult.IsError != true, $"Setup: Failed to create book for deletion test. {GetFirstTextContent(createResult)}"); + + JsonElement root = ParseResultRoot(createResult); + if (root.TryGetProperty("result", out JsonElement resultElement) && + resultElement.ValueKind == JsonValueKind.Object && + resultElement.TryGetProperty("value", out JsonElement valueArray) && + valueArray.ValueKind == JsonValueKind.Array && + valueArray.GetArrayLength() > 0) + { + return valueArray[0].GetProperty("id").GetInt32(); + } + + Assert.Fail("Could not extract ID from created book record."); + return -1; + } + + private static async Task ExecuteDeleteAsync(string entity, Dictionary keys) + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + DeleteRecordTool tool = new(); + + var args = new Dictionary + { + { "entity", entity }, + { "keys", keys } + }; + + return await ExecuteToolAsync(tool, serviceProvider, args); + } + } +} diff --git a/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs index 5744fb7c3a..9ebadeacdd 100644 --- a/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/DynamicCustomToolMsSqlIntegrationTests.cs @@ -1,29 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; -using System.Security.Claims; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Azure.DataApiBuilder.Config.ObjectModel; -using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Configurations; -using Azure.DataApiBuilder.Core.Resolvers; -using Azure.DataApiBuilder.Core.Resolvers.Factories; -using Azure.DataApiBuilder.Core.Services.Cache; using Azure.DataApiBuilder.Mcp.Core; -using Azure.DataApiBuilder.Service.Tests.SqlTests; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using ModelContextProtocol.Protocol; -using Moq; -using ZiggyCreatures.Caching.Fusion; namespace Azure.DataApiBuilder.Service.Tests.Mcp { @@ -40,7 +28,7 @@ namespace Azure.DataApiBuilder.Service.Tests.Mcp /// - GetBooks -> SP get_books, zero params /// [TestClass, TestCategory(TestCategory.MSSQL)] - public class DynamicCustomToolMsSqlIntegrationTests : SqlTestBase + public class DynamicCustomToolMsSqlIntegrationTests : McpToolTestBase { [ClassInitialize] public static async Task SetupAsync(TestContext context) @@ -79,8 +67,6 @@ public async Task DynamicCustomTool_SuccessfulExecution(string entityName, strin /// /// Verify GetBook with id=1 returns a matching record through DynamicCustomTool. - /// Unlike SuccessfulExecution data rows (which validate response structure only), - /// this test validates the actual returned data content (id field in the result). /// [TestMethod] public async Task DynamicCustomTool_GetBookById_ReturnsMatchingRecord() @@ -115,10 +101,8 @@ public async Task DynamicCustomTool_InvalidParamName_ReturnsError(string entityN Dictionary parameters = new() { { paramName, paramValue } }; CallToolResult result = await ExecuteCustomToolAsync(entityName, parameters); - Assert.IsTrue(result.IsError == true, + AssertError(result, paramName, $"Custom tool should reject parameter '{paramName}' not in DB metadata for '{entityName}'."); - string content = GetFirstTextContent(result); - StringAssert.Contains(content, paramName); } #region Schema Alignment Integration Tests @@ -132,7 +116,7 @@ public async Task DynamicCustomTool_InvalidParamName_ReturnsError(string entityN [DataRow("InsertBook", "publisher_id", "integer", DisplayName = "int param maps to integer (multi-param SP)")] public void InitializeMetadata_SchemaReflectsDbParameterTypes(string entityName, string paramName, string expectedType) { - IServiceProvider serviceProvider = BuildServiceProvider(); + IServiceProvider serviceProvider = BuildQueryServiceProvider(); RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService(); Entity entity = configProvider.GetConfig().Entities[entityName]; @@ -153,7 +137,7 @@ public void InitializeMetadata_SchemaReflectsDbParameterTypes(string entityName, [TestMethod] public void InitializeMetadata_ZeroParamSP_HasEmptyProperties() { - IServiceProvider serviceProvider = BuildServiceProvider(); + IServiceProvider serviceProvider = BuildQueryServiceProvider(); RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService(); Entity entity = configProvider.GetConfig().Entities["GetBooks"]; @@ -179,7 +163,7 @@ public void InitializeMetadata_ZeroParamSP_HasEmptyProperties() [DataRow("InsertBook", "publisher_id", "1234", DisplayName = "publisher_id description includes default '1234'")] public void InitializeMetadata_DescriptionIncludesConfigDefaults(string entityName, string paramName, string expectedDefault) { - IServiceProvider serviceProvider = BuildServiceProvider(); + IServiceProvider serviceProvider = BuildQueryServiceProvider(); RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService(); Entity entity = configProvider.GetConfig().Entities[entityName]; @@ -200,9 +184,8 @@ public void InitializeMetadata_DescriptionIncludesConfigDefaults(string entityNa /// private static async Task ExecuteCustomToolAsync(string entityName, Dictionary? parameters) { - IServiceProvider serviceProvider = BuildServiceProvider(); + IServiceProvider serviceProvider = BuildQueryServiceProvider(); - // Resolve the entity config from the runtime config RuntimeConfigProvider configProvider = serviceProvider.GetRequiredService(); RuntimeConfig config = configProvider.GetConfig(); Entity entity = config.Entities[entityName]; @@ -217,72 +200,5 @@ private static async Task ExecuteCustomToolAsync(string entityNa return await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); } - - /// - /// Builds a service provider wired to the shared fixture's real providers. - /// Uses the same pattern as ExecuteEntityToolMsSqlIntegrationTests. - /// - private static IServiceProvider BuildServiceProvider() - { - ServiceCollection services = new(); - - RuntimeConfigProvider configProvider = _application.Services.GetRequiredService(); - services.AddSingleton(configProvider); - - services.AddSingleton(_metadataProviderFactory.Object); - services.AddSingleton(_authorizationResolver); - - DefaultHttpContext httpContext = new(); - httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = AuthorizationResolver.ROLE_ANONYMOUS; - ClaimsIdentity identity = new( - authenticationType: "TestAuth", - nameType: null, - roleType: AuthenticationOptions.ROLE_CLAIM_TYPE); - identity.AddClaim(new Claim(AuthenticationOptions.ROLE_CLAIM_TYPE, AuthorizationResolver.ROLE_ANONYMOUS)); - httpContext.User = new ClaimsPrincipal(identity); - IHttpContextAccessor httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; - services.AddSingleton(httpContextAccessor); - - Mock cache = new(); - DabCacheService cacheService = new(cache.Object, logger: null, httpContextAccessor); - - SqlQueryEngine queryEngine = new( - _queryManagerFactory.Object, - _metadataProviderFactory.Object, - httpContextAccessor, - _authorizationResolver, - _gqlFilterParser, - new Mock>().Object, - configProvider, - cacheService); - - Mock queryEngineFactory = new(); - queryEngineFactory - .Setup(f => f.GetQueryEngine(It.IsAny())) - .Returns(queryEngine); - services.AddSingleton(queryEngineFactory.Object); - - services.AddLogging(); - - return services.BuildServiceProvider(); - } - - private static string GetFirstTextContent(CallToolResult result) - { - if (result.Content is null || result.Content.Count == 0) - { - return string.Empty; - } - - return result.Content[0] is TextContentBlock textBlock - ? textBlock.Text ?? string.Empty - : string.Empty; - } - - private static void AssertSuccess(CallToolResult result, string message) - { - Assert.IsTrue(result.IsError != true, - $"{message} Content: {GetFirstTextContent(result)}"); - } } } diff --git a/src/Service.Tests/Mcp/DynamicCustomToolTests.cs b/src/Service.Tests/Mcp/DynamicCustomToolTests.cs index 6509c64b0a..bb67aec5a0 100644 --- a/src/Service.Tests/Mcp/DynamicCustomToolTests.cs +++ b/src/Service.Tests/Mcp/DynamicCustomToolTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; using System.Linq; diff --git a/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs index 624990dc38..f4089d8e19 100644 --- a/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs +++ b/src/Service.Tests/Mcp/ExecuteEntityToolMsSqlIntegrationTests.cs @@ -1,47 +1,29 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; -using System.Security.Claims; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; -using Azure.DataApiBuilder.Config.ObjectModel; -using Azure.DataApiBuilder.Core.Authorization; -using Azure.DataApiBuilder.Core.Configurations; -using Azure.DataApiBuilder.Core.Resolvers; -using Azure.DataApiBuilder.Core.Resolvers.Factories; -using Azure.DataApiBuilder.Core.Services.Cache; using Azure.DataApiBuilder.Mcp.BuiltInTools; -using Azure.DataApiBuilder.Service.Tests.SqlTests; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using ModelContextProtocol.Protocol; -using Moq; -using ZiggyCreatures.Caching.Fusion; namespace Azure.DataApiBuilder.Service.Tests.Mcp { /// /// Integration tests for ExecuteEntityTool's parameter validation and default application. - /// Verifies the end-to-end behavior after the fix: - /// - Parameters are validated against StoredProcedureDefinition.Parameters (DB metadata), - /// not config-side parameters alone. - /// - Config defaults are applied from ParameterDefinition.HasConfigDefault/ConfigDefaultValue - /// for any parameter the user did not supply. + /// Verifies: + /// - Parameters validated against StoredProcedureDefinition.Parameters (DB metadata). + /// - Config defaults applied from ParameterDefinition.HasConfigDefault/ConfigDefaultValue. /// - /// Scenarios (reuse SPs already defined in DatabaseSchema-MsSql.sql / dab-config.MsSql.json): + /// Uses SPs defined in DatabaseSchema-MsSql.sql / dab-config.MsSql.json: /// - GetBook -> SP get_book_by_id(@id int), no config params. /// - InsertBook -> SP insert_book(@title, @publisher_id), config defaults applied. /// - GetBooks -> SP get_books, zero params. /// [TestClass, TestCategory(TestCategory.MSSQL)] - public class ExecuteEntityToolMsSqlIntegrationTests : SqlTestBase + public class ExecuteEntityToolMsSqlIntegrationTests : McpToolTestBase { [ClassInitialize] public static async Task SetupAsync(TestContext context) @@ -52,11 +34,6 @@ public static async Task SetupAsync(TestContext context) /// /// Data-driven test validating successful SP execution across multiple parameter scenarios. - /// Each row exercises a distinct code path in ExecuteEntityTool: - /// - DB-discovered param with no config entry (validates the fix for param validation). - /// - Config defaults applied when user omits params. - /// - User-supplied params override config defaults. - /// - Zero-param SP succeeds with no parameters. /// [DataTestMethod] [DataRow("GetBook", "{\"id\": 1}", DisplayName = "DB-discovered param accepted (no config entry)")] @@ -74,7 +51,6 @@ public async Task ExecuteEntity_SuccessfulExecution(string entityName, string? p AssertSuccess(result, $"execute_entity failed for entity '{entityName}' with params '{parametersJson}'."); - // Parse response and verify structure string content = GetFirstTextContent(result); Assert.IsFalse(string.IsNullOrWhiteSpace(content), $"Expected non-empty result for entity '{entityName}'."); @@ -86,7 +62,6 @@ public async Task ExecuteEntity_SuccessfulExecution(string entityName, string? p /// /// Verify that GetBook with id=1 returns the actual book record from the database. - /// This ensures the parameter value is correctly passed to the stored procedure. /// [TestMethod] public async Task ExecuteEntity_GetBookById_ReturnsMatchingRecord() @@ -99,12 +74,8 @@ public async Task ExecuteEntity_GetBookById_ReturnsMatchingRecord() using JsonDocument doc = JsonDocument.Parse(GetFirstTextContent(result)); JsonElement root = doc.RootElement; - // Verify the value property contains the SP result with at least one record with id=1. - // SqlResponseHelpers.OkResponse wraps results in { value: [...] }, and - // BuildExecuteSuccessResponse serializes that as-is into the "value" field. Assert.IsTrue(root.TryGetProperty("value", out JsonElement valueWrapper), "Response should contain 'value' property."); - // The value may be the wrapper object { "value": [...] } or directly an array. JsonElement records = valueWrapper.ValueKind == JsonValueKind.Object ? valueWrapper.GetProperty("value") : valueWrapper; @@ -115,8 +86,7 @@ public async Task ExecuteEntity_GetBookById_ReturnsMatchingRecord() } /// - /// Verify that InsertBook with no user params applies config defaults (title="randomX", publisher_id="1234"). - /// The SP inserts using those defaults. We verify the tool reports success (the SP executed without error). + /// Verify that InsertBook with no user params applies config defaults. /// [TestMethod] public async Task ExecuteEntity_InsertBookWithDefaults_ExecutesSuccessfully() @@ -132,7 +102,6 @@ public async Task ExecuteEntity_InsertBookWithDefaults_ExecutesSuccessfully() /// /// Reject a parameter name that does not exist in the DB metadata. - /// Validation against StoredProcedureDefinition.Parameters should catch this. /// [DataTestMethod] [DataRow("GetBook", "nonexistent_param", "value", DisplayName = "Rejects unknown param on single-param SP")] @@ -142,108 +111,22 @@ public async Task ExecuteEntity_InvalidParamName_ReturnsError(string entityName, Dictionary parameters = new() { { paramName, paramValue } }; CallToolResult result = await ExecuteEntityAsync(entityName, parameters); - Assert.IsTrue(result.IsError == true, + AssertError(result, paramName, $"execute_entity should reject parameter '{paramName}' not in DB metadata for '{entityName}'."); - string content = GetFirstTextContent(result); - StringAssert.Contains(content, paramName); } private static async Task ExecuteEntityAsync(string entityName, Dictionary? parameters) { - IServiceProvider serviceProvider = BuildExecuteEntityServiceProvider(); + IServiceProvider serviceProvider = BuildQueryServiceProvider(); ExecuteEntityTool tool = new(); - var args = new Dictionary { { "entity", entityName } }; + var args = new Dictionary { { "entity", entityName } }; if (parameters != null) { args["parameters"] = parameters; } - string argsJson = JsonSerializer.Serialize(args); - using JsonDocument arguments = JsonDocument.Parse(argsJson); - - return await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); - } - - /// - /// Builds a service provider that wires ExecuteEntityTool to the shared fixture's - /// real ISqlMetadataProvider, real IQueryEngine (SqlQueryEngine), and real - /// authorization resolver, with a DefaultHttpContext carrying the anonymous role header. - /// Uses the RuntimeConfigProvider from the WebApplicationFactory so that the datasource - /// name matches what the real MsSqlQueryExecutor was initialized with. - /// - private static IServiceProvider BuildExecuteEntityServiceProvider() - { - ServiceCollection services = new(); - - // Use the RuntimeConfigProvider from the WebApplicationFactory — this is the same - // provider that initialized _queryExecutor, so its DefaultDataSourceName matches - // the key in _queryExecutor.ConnectionStringBuilders. - RuntimeConfigProvider configProvider = _application.Services.GetRequiredService(); - services.AddSingleton(configProvider); - - // Real metadata-provider factory backed by the shared fixture's live provider. - services.AddSingleton(_metadataProviderFactory.Object); - - // Real authorization resolver wired by SqlTestBase against the live config + provider. - services.AddSingleton(_authorizationResolver); - - // Real HttpContext carrying the anonymous role header and a ClaimsPrincipal - // with the anonymous role claim so that AuthorizationResolver.IsValidRoleContext - // (which calls httpContext.User.IsInRole) returns true. - DefaultHttpContext httpContext = new(); - httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = AuthorizationResolver.ROLE_ANONYMOUS; - ClaimsIdentity identity = new( - authenticationType: "TestAuth", - nameType: null, - roleType: AuthenticationOptions.ROLE_CLAIM_TYPE); - identity.AddClaim(new Claim(AuthenticationOptions.ROLE_CLAIM_TYPE, AuthorizationResolver.ROLE_ANONYMOUS)); - httpContext.User = new ClaimsPrincipal(identity); - IHttpContextAccessor httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; - services.AddSingleton(httpContextAccessor); - - // Build a real SqlQueryEngine using the shared fixtures. - Mock cache = new(); - DabCacheService cacheService = new(cache.Object, logger: null, httpContextAccessor); - - SqlQueryEngine queryEngine = new( - _queryManagerFactory.Object, - _metadataProviderFactory.Object, - httpContextAccessor, - _authorizationResolver, - _gqlFilterParser, - new Mock>().Object, - configProvider, - cacheService); - - // Wrap in a mock IQueryEngineFactory that returns the real engine. - Mock queryEngineFactory = new(); - queryEngineFactory - .Setup(f => f.GetQueryEngine(It.IsAny())) - .Returns(queryEngine); - services.AddSingleton(queryEngineFactory.Object); - - services.AddLogging(); - - return services.BuildServiceProvider(); - } - - private static string GetFirstTextContent(CallToolResult result) - { - if (result.Content is null || result.Content.Count == 0) - { - return string.Empty; - } - - return result.Content[0] is TextContentBlock textBlock - ? textBlock.Text ?? string.Empty - : string.Empty; - } - - private static void AssertSuccess(CallToolResult result, string message) - { - Assert.IsTrue(result.IsError != true, - $"{message} Content: {GetFirstTextContent(result)}"); + return await ExecuteToolAsync(tool, serviceProvider, args); } } } diff --git a/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs b/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs index 2410c1ab8b..4cc7b352fd 100644 --- a/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs +++ b/src/Service.Tests/Mcp/ExecuteEntityToolTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; using System.Text.Json; diff --git a/src/Service.Tests/Mcp/McpToolRegistryTests.cs b/src/Service.Tests/Mcp/McpToolRegistryTests.cs index 14f966d81b..d8c5dc0b59 100644 --- a/src/Service.Tests/Mcp/McpToolRegistryTests.cs +++ b/src/Service.Tests/Mcp/McpToolRegistryTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#nullable enable - using System; using System.Collections.Generic; using System.Linq; diff --git a/src/Service.Tests/Mcp/McpToolTestBase.cs b/src/Service.Tests/Mcp/McpToolTestBase.cs new file mode 100644 index 0000000000..963d3ac165 --- /dev/null +++ b/src/Service.Tests/Mcp/McpToolTestBase.cs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Authorization; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Core.Resolvers; +using Azure.DataApiBuilder.Core.Resolvers.Factories; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Core.Services.Cache; +using Azure.DataApiBuilder.Mcp.Model; +using Azure.DataApiBuilder.Service.Tests.SqlTests; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; +using Moq; +using ZiggyCreatures.Caching.Fusion; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Shared base class for MCP tool integration tests. + /// Provides common service provider builders and assertion helpers to avoid duplication. + /// + public abstract class McpToolTestBase : SqlTestBase + { + #region Service Provider Builders + + /// + /// Builds a service provider for read-only tools (ReadRecordsTool, AggregateRecordsTool). + /// Includes: RuntimeConfigProvider, IMetadataProviderFactory, IAuthorizationResolver, + /// IAuthorizationService, IHttpContextAccessor, IQueryEngineFactory, GQLFilterParser, + /// IAbstractQueryManagerFactory. + /// + protected static IServiceProvider BuildQueryServiceProvider() + { + ServiceCollection services = new(); + + RuntimeConfigProvider configProvider = _application.Services.GetRequiredService(); + services.AddSingleton(configProvider); + + services.AddSingleton(_metadataProviderFactory.Object); + services.AddSingleton(_authorizationResolver); + services.AddSingleton(_gqlFilterParser); + services.AddSingleton(_queryManagerFactory.Object); + + IHttpContextAccessor httpContextAccessor = CreateAnonymousHttpContextAccessor(); + services.AddSingleton(httpContextAccessor); + + Mock authorizationService = new(); + authorizationService + .Setup(a => a.AuthorizeAsync(It.IsAny(), It.IsAny(), It.IsAny>())) + .ReturnsAsync(AuthorizationResult.Success()); + services.AddSingleton(authorizationService.Object); + + Mock cache = new(); + DabCacheService cacheService = new(cache.Object, logger: null, httpContextAccessor); + + SqlQueryEngine queryEngine = new( + _queryManagerFactory.Object, + _metadataProviderFactory.Object, + httpContextAccessor, + _authorizationResolver, + _gqlFilterParser, + new Mock>().Object, + configProvider, + cacheService); + + Mock queryEngineFactory = new(); + queryEngineFactory + .Setup(f => f.GetQueryEngine(It.IsAny())) + .Returns(queryEngine); + services.AddSingleton(queryEngineFactory.Object); + + services.AddLogging(); + + return services.BuildServiceProvider(); + } + + /// + /// Builds a service provider for mutation tools (CreateRecordTool, UpdateRecordTool, DeleteRecordTool). + /// Includes: RuntimeConfigProvider, IMetadataProviderFactory, IAuthorizationResolver, + /// IHttpContextAccessor, IMutationEngineFactory, RequestValidator. + /// + protected static IServiceProvider BuildMutationServiceProvider() + { + ServiceCollection services = new(); + + RuntimeConfigProvider configProvider = _application.Services.GetRequiredService(); + services.AddSingleton(configProvider); + + services.AddSingleton(_metadataProviderFactory.Object); + services.AddSingleton(_authorizationResolver); + + IHttpContextAccessor httpContextAccessor = CreateAnonymousHttpContextAccessor(); + services.AddSingleton(httpContextAccessor); + + services.AddSingleton(new RequestValidator(_metadataProviderFactory.Object, configProvider)); + + Mock cache = new(); + DabCacheService cacheService = new(cache.Object, logger: null, httpContextAccessor); + + SqlQueryEngine queryEngine = new( + _queryManagerFactory.Object, + _metadataProviderFactory.Object, + httpContextAccessor, + _authorizationResolver, + _gqlFilterParser, + new Mock>().Object, + configProvider, + cacheService); + + Mock queryEngineFactory = new(); + queryEngineFactory + .Setup(f => f.GetQueryEngine(It.IsAny())) + .Returns(queryEngine); + + SqlMutationEngine mutationEngine = new( + _queryManagerFactory.Object, + _metadataProviderFactory.Object, + queryEngineFactory.Object, + _authorizationResolver, + _gqlFilterParser, + httpContextAccessor, + configProvider); + + Mock mutationEngineFactory = new(); + mutationEngineFactory + .Setup(f => f.GetMutationEngine(It.IsAny())) + .Returns(mutationEngine); + services.AddSingleton(mutationEngineFactory.Object); + + services.AddLogging(); + + return services.BuildServiceProvider(); + } + + #endregion + + #region Helpers + + /// + /// Creates an HttpContextAccessor with anonymous role claims for MCP tool testing. + /// + protected static IHttpContextAccessor CreateAnonymousHttpContextAccessor() + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = AuthorizationResolver.ROLE_ANONYMOUS; + ClaimsIdentity identity = new( + authenticationType: "TestAuth", + nameType: null, + roleType: AuthenticationOptions.ROLE_CLAIM_TYPE); + identity.AddClaim(new Claim(AuthenticationOptions.ROLE_CLAIM_TYPE, AuthorizationResolver.ROLE_ANONYMOUS)); + httpContext.User = new ClaimsPrincipal(identity); + return new HttpContextAccessor { HttpContext = httpContext }; + } + + /// + /// Executes an MCP tool with the given JSON arguments and service provider. + /// + protected static async Task ExecuteToolAsync(IMcpTool tool, IServiceProvider serviceProvider, Dictionary args) + { + string argsJson = JsonSerializer.Serialize(args); + using JsonDocument arguments = JsonDocument.Parse(argsJson); + return await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + } + + /// + /// Extracts the text content from the first content block of a CallToolResult. + /// + protected static string GetFirstTextContent(CallToolResult result) + { + if (result.Content is null || result.Content.Count == 0) + { + return string.Empty; + } + + return result.Content[0] is TextContentBlock textBlock + ? textBlock.Text ?? string.Empty + : string.Empty; + } + + /// + /// Asserts that a CallToolResult is not an error. + /// + protected static void AssertSuccess(CallToolResult result, string message) + { + Assert.IsTrue(result.IsError != true, + $"{message} Content: {GetFirstTextContent(result)}"); + } + + /// + /// Asserts that a CallToolResult is an error and its content contains the expected substring. + /// + protected static void AssertError(CallToolResult result, string? expectedSubstring = null, string? message = null) + { + Assert.IsTrue(result.IsError == true, message ?? "Expected an error result."); + if (expectedSubstring != null) + { + StringAssert.Contains(GetFirstTextContent(result), expectedSubstring); + } + } + + /// + /// Parses the first text content of a result into a JsonElement root. + /// + protected static JsonElement ParseResultRoot(CallToolResult result) + { + string content = GetFirstTextContent(result); + Assert.IsFalse(string.IsNullOrWhiteSpace(content), "Expected non-empty result content."); + return JsonDocument.Parse(content).RootElement; + } + + #endregion + } +} diff --git a/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs new file mode 100644 index 0000000000..e5f9b6b481 --- /dev/null +++ b/src/Service.Tests/Mcp/ReadRecordsToolMsSqlIntegrationTests.cs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Integration tests for ReadRecordsTool against a real MsSql database. + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class ReadRecordsToolMsSqlIntegrationTests : McpToolTestBase + { + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + /// + /// Reads all records from the Book entity without any filters. + /// + [TestMethod] + public async Task ReadRecords_AllBooks_ReturnsResults() + { + CallToolResult result = await ExecuteReadAsync("Book"); + + AssertSuccess(result, "ReadRecords for Book should succeed."); + + JsonElement root = ParseResultRoot(result); + Assert.AreEqual("Book", root.GetProperty("entity").GetString()); + + JsonElement records = root.GetProperty("result").GetProperty("value"); + Assert.AreEqual(JsonValueKind.Array, records.ValueKind); + Assert.IsTrue(records.GetArrayLength() > 0, "Expected at least one book record."); + } + + /// + /// Reads records with a select clause to retrieve only specific fields. + /// + [TestMethod] + public async Task ReadRecords_WithSelect_ReturnsSelectedFields() + { + CallToolResult result = await ExecuteReadAsync("Book", select: "id,title"); + + AssertSuccess(result, "ReadRecords with select should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement firstRecord = root.GetProperty("result").GetProperty("value")[0]; + Assert.IsTrue(firstRecord.TryGetProperty("id", out _), "Expected 'id' field in result."); + Assert.IsTrue(firstRecord.TryGetProperty("title", out _), "Expected 'title' field in result."); + } + + /// + /// Reads records with an OData filter expression and verifies filtered results are returned. + /// + [TestMethod] + public async Task ReadRecords_WithFilter_ReturnsFilteredResults() + { + CallToolResult result = await ExecuteReadAsync("Book", filter: "id gt 5"); + + AssertSuccess(result, "ReadRecords with filter should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement resultElement = root.GetProperty("result"); + JsonElement records = resultElement.ValueKind == JsonValueKind.Object && resultElement.TryGetProperty("value", out JsonElement valueElement) + ? valueElement + : resultElement; + Assert.IsTrue(records.GetArrayLength() > 0, "Expected filtered results."); + + foreach (JsonElement record in records.EnumerateArray()) + { + Assert.IsTrue(record.GetProperty("id").GetInt32() > 5, + "All filtered records should have id > 5."); + } + } + + /// + /// Reads records with orderby to verify sorting. + /// + [TestMethod] + public async Task ReadRecords_WithOrderBy_ReturnsSortedResults() + { + CallToolResult result = await ExecuteReadAsync("Book", select: "id,title", orderby: new[] { "id desc" }); + + AssertSuccess(result, "ReadRecords with orderby should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement records = root.GetProperty("result").GetProperty("value"); + Assert.IsTrue(records.GetArrayLength() > 1, "Expected multiple records for ordering test."); + + int previousId = int.MaxValue; + foreach (JsonElement record in records.EnumerateArray()) + { + int currentId = record.GetProperty("id").GetInt32(); + Assert.IsTrue(currentId <= previousId, $"Records should be in descending order. Got {currentId} after {previousId}."); + previousId = currentId; + } + } + + /// + /// Reads records with first parameter to limit page size. + /// + [TestMethod] + public async Task ReadRecords_WithFirst_ReturnsLimitedResults() + { + CallToolResult result = await ExecuteReadAsync("Book", first: 3); + + AssertSuccess(result, "ReadRecords with first should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement records = root.GetProperty("result").GetProperty("value"); + Assert.AreEqual(3, records.GetArrayLength(), "Expected exactly 3 records when first=3."); + } + + /// + /// Reads a single record by primary key filter. + /// + [TestMethod] + public async Task ReadRecords_FilterById_ReturnsSingleRecord() + { + CallToolResult result = await ExecuteReadAsync("Book", filter: "id eq 1"); + + AssertSuccess(result, "ReadRecords with id filter should succeed."); + + JsonElement root = ParseResultRoot(result); + JsonElement resultElement = root.GetProperty("result"); + JsonElement records = resultElement.ValueKind == JsonValueKind.Object && resultElement.TryGetProperty("value", out JsonElement valueElement) + ? valueElement + : resultElement; + Assert.AreEqual(1, records.GetArrayLength(), "Expected exactly one record with id=1."); + Assert.AreEqual(1, records[0].GetProperty("id").GetInt32()); + } + + /// + /// Reads records for a non-existent entity, expecting an error. + /// + [TestMethod] + public async Task ReadRecords_InvalidEntity_ReturnsError() + { + CallToolResult result = await ExecuteReadAsync("NonExistentEntity"); + + AssertError(result, "NonExistentEntity"); + } + + private static async Task ExecuteReadAsync( + string entity, + string? select = null, + string? filter = null, + string[]? orderby = null, + int? first = null, + string? after = null) + { + IServiceProvider serviceProvider = BuildQueryServiceProvider(); + ReadRecordsTool tool = new(); + + var args = new Dictionary { { "entity", entity } }; + + if (select != null) + { + args["select"] = select; + } + + if (filter != null) + { + args["filter"] = filter; + } + + if (orderby != null) + { + args["orderby"] = orderby; + } + + if (first != null) + { + args["first"] = first; + } + + if (after != null) + { + args["after"] = after; + } + + return await ExecuteToolAsync(tool, serviceProvider, args); + } + } +} diff --git a/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs b/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs new file mode 100644 index 0000000000..9d1286a2c2 --- /dev/null +++ b/src/Service.Tests/Mcp/UpdateRecordToolMsSqlIntegrationTests.cs @@ -0,0 +1,222 @@ +// 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.Mcp.BuiltInTools; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Integration tests for UpdateRecordTool against a real MsSql database. + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class UpdateRecordToolMsSqlIntegrationTests : McpToolTestBase + { + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + /// + /// Creates a dedicated record, updates its title, verifies success, then cleans up. + /// + [TestMethod] + public async Task UpdateRecord_ValidKeysAndFields_ReturnsSuccess() + { + int createdId = await CreateBookForUpdate("Book Before Update"); + try + { + var keys = new Dictionary { { "id", createdId } }; + var fields = new Dictionary { { "title", "Updated Title via MCP" } }; + + CallToolResult result = await ExecuteUpdateAsync("Book", keys, fields); + + AssertSuccess(result, "UpdateRecord should succeed for existing record."); + + JsonElement root = ParseResultRoot(result); + Assert.AreEqual("Book", root.GetProperty("entity").GetString()); + Assert.IsTrue(root.GetProperty("message").GetString()!.Contains("Successfully updated"), + "Response message should indicate success."); + + if (root.TryGetProperty("result", out JsonElement resultElement) && + resultElement.TryGetProperty("title", out JsonElement titleElement)) + { + Assert.AreEqual("Updated Title via MCP", titleElement.GetString()); + } + } + finally + { + await DeleteBook(createdId); + } + } + + /// + /// Creates a dedicated record, updates its publisher_id, verifies the change, then cleans up. + /// + [TestMethod] + public async Task UpdateRecord_UpdateNumericField_ReturnsUpdatedValue() + { + int createdId = await CreateBookForUpdate("Numeric Field Update Book"); + try + { + var keys = new Dictionary { { "id", createdId } }; + var fields = new Dictionary { { "publisher_id", 2345 } }; + + CallToolResult result = await ExecuteUpdateAsync("Book", keys, fields); + + AssertSuccess(result, "UpdateRecord should succeed for numeric field update."); + + JsonElement root = ParseResultRoot(result); + if (root.TryGetProperty("result", out JsonElement resultElement) && + resultElement.TryGetProperty("publisher_id", out JsonElement pubElement)) + { + Assert.AreEqual(2345, pubElement.GetInt32()); + } + } + finally + { + await DeleteBook(createdId); + } + } + + /// + /// Validates error scenarios for UpdateRecordTool. + /// + [DataTestMethod] + [DataRow(99999, "Book", DisplayName = "Non-existent key")] + public async Task UpdateRecord_NonExistentKey_ReturnsError(int keyId, string entity) + { + var keys = new Dictionary { { "id", keyId } }; + var fields = new Dictionary { { "title", "Ghost Book" } }; + + CallToolResult result = await ExecuteUpdateAsync(entity, keys, fields); + + AssertError(result); + } + + /// + /// Attempts to update a non-existent entity, expecting an error. + /// + [TestMethod] + public async Task UpdateRecord_InvalidEntity_ReturnsError() + { + var keys = new Dictionary { { "id", 1 } }; + var fields = new Dictionary { { "name", "Test" } }; + + CallToolResult result = await ExecuteUpdateAsync("NonExistentEntity", keys, fields); + + AssertError(result, "NonExistentEntity"); + } + + /// + /// Attempts to update with no arguments, expecting an error. + /// + [TestMethod] + public async Task UpdateRecord_NoArguments_ReturnsError() + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + UpdateRecordTool tool = new(); + + CallToolResult result = await tool.ExecuteAsync(null, serviceProvider, CancellationToken.None); + + AssertError(result); + } + + /// + /// Attempts to update with null key value, expecting an error. + /// + [TestMethod] + public async Task UpdateRecord_NullKeyValue_ReturnsError() + { + var args = new Dictionary + { + { "entity", "Book" }, + { "keys", new Dictionary { { "id", null } } }, + { "fields", new Dictionary { { "title", "Test" } } } + }; + + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + UpdateRecordTool tool = new(); + + string argsJson = JsonSerializer.Serialize(args); + using JsonDocument arguments = JsonDocument.Parse(argsJson); + + CallToolResult result = await tool.ExecuteAsync(arguments, serviceProvider, CancellationToken.None); + + AssertError(result); + } + + private static async Task ExecuteUpdateAsync( + string entity, + Dictionary keys, + Dictionary fields) + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + UpdateRecordTool tool = new(); + + var args = new Dictionary + { + { "entity", entity }, + { "keys", keys }, + { "fields", fields } + }; + + return await ExecuteToolAsync(tool, serviceProvider, args); + } + + private static async Task CreateBookForUpdate(string title) + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + CreateRecordTool createTool = new(); + + var args = new Dictionary + { + { "entity", "Book" }, + { "data", new Dictionary { { "title", title }, { "publisher_id", 1234 } } } + }; + + CallToolResult createResult = await ExecuteToolAsync(createTool, serviceProvider, args); + Assert.IsTrue(createResult.IsError != true, $"Setup: Failed to create book for update test. {GetFirstTextContent(createResult)}"); + + JsonElement root = ParseResultRoot(createResult); + if (root.TryGetProperty("result", out JsonElement resultElement) && + resultElement.ValueKind == JsonValueKind.Object && + resultElement.TryGetProperty("value", out JsonElement valueArray) && + valueArray.ValueKind == JsonValueKind.Array && + valueArray.GetArrayLength() > 0) + { + return valueArray[0].GetProperty("id").GetInt32(); + } + + if (resultElement.ValueKind == JsonValueKind.Array && resultElement.GetArrayLength() > 0) + { + return resultElement[0].GetProperty("id").GetInt32(); + } + + Assert.Fail("Could not extract created book ID from response."); + return -1; + } + + private static async Task DeleteBook(int id) + { + IServiceProvider serviceProvider = BuildMutationServiceProvider(); + DeleteRecordTool deleteTool = new(); + + var args = new Dictionary + { + { "entity", "Book" }, + { "keys", new Dictionary { { "id", id } } } + }; + + await ExecuteToolAsync(deleteTool, serviceProvider, args); + } + } +}